| 1 | package apperr |
| 2 | |
| 3 | import ( |
| 4 | "strconv" |
| 5 | |
| 6 | "github.com/carlosframework/platform/internal/applog" |
| 7 | ) |
| 8 | |
| 9 | // Availability is the Errors tab's sibling (spec 2026-09-07 §3.2, |
| 10 | // amendment 2026-09-24): the failures that are about whether an instance |
| 11 | // could answer at all, not about what the request asked it to do. A 502 |
| 12 | // on /admin/sandbox says nothing about the sandbox page; it says the |
| 13 | // instance was down, restarting or unreachable when that page was asked |
| 14 | // for. So these group by CAUSE, never by path: one outage is one row, and |
| 15 | // a scanner probing forty paths of a dead instance is one row, not forty. |
| 16 | |
| 17 | // KindUnavailable marks an edge record filed under Availability. Records |
| 18 | // written before the amendment carry KindHTTP and no Cause; IsAvailability |
| 19 | // still recognises them by status, and AsAvailability regroups them. |
| 20 | const KindUnavailable = "unavailable" |
| 21 | |
| 22 | // Causes the edge records for a 502/503/504, from the proxy error that |
| 23 | // produced it (edge.proxyCause). The vocabulary is closed: an unknown |
| 24 | // value on read renders as itself, never guessed into one of these. |
| 25 | const ( |
| 26 | CauseRefused = "refused" // nothing listening: the instance is down or mid-restart |
| 27 | CauseReset = "reset" // the instance hung up mid-request |
| 28 | CauseTimeout = "timeout" // the instance did not answer in time |
| 29 | CauseAbandoned = "abandoned" // the visitor gave up after waiting long enough that the wait was the problem |
| 30 | CauseNoUpstream = "no_upstream" // every upstream of a multi-upstream route is failing its probe |
| 31 | CauseFleetDark = "fleet_dark" // no box of the route's fleet is connected |
| 32 | CauseTunnel = "tunnel" // the tunnel to the fleet box failed |
| 33 | CauseStatic = "static" // a static site's files could not be resolved |
| 34 | CauseProxy = "proxy" // any other proxy failure |
| 35 | CauseUpstream = "upstream" // the app itself answered 502/503/504 |
| 36 | ) |
| 37 | |
| 38 | var causeText = map[string]string{ |
| 39 | CauseRefused: "the instance refused the connection", |
| 40 | CauseReset: "the instance closed the connection mid-request", |
| 41 | CauseTimeout: "the instance did not answer in time", |
| 42 | CauseAbandoned: "the visitor gave up waiting for the instance", |
| 43 | CauseNoUpstream: "no healthy upstream", |
| 44 | CauseFleetDark: "no box of the fleet is connected", |
| 45 | CauseTunnel: "the tunnel to the fleet box failed", |
| 46 | CauseStatic: "the static site could not be read", |
| 47 | CauseProxy: "the proxy could not complete the request", |
| 48 | CauseUpstream: "the app answered it itself", |
| 49 | "": "cause not recorded", |
| 50 | } |
| 51 | |
| 52 | // UnavailableStatus reports whether an edge-observed status belongs under |
| 53 | // Availability rather than Errors. 500, 501 and 505+ stay errors: the app |
| 54 | // answered and what it answered was a failure of the request. |
| 55 | func UnavailableStatus(status int) bool { return status >= 502 && status <= 504 } |
| 56 | |
| 57 | // AvailabilityName is the row name for one (status, cause): the status and |
| 58 | // the cause in the member's terms, and deliberately no path. |
| 59 | func AvailabilityName(status int, cause string) string { |
| 60 | text, ok := causeText[cause] |
| 61 | if !ok { |
| 62 | text = cause |
| 63 | } |
| 64 | return strconv.Itoa(status) + " · " + text |
| 65 | } |
| 66 | |
| 67 | // IsAvailability reports whether r belongs under Availability: an edge |
| 68 | // 502/503/504 (whichever version of the edge wrote it), or a platform |
| 69 | // event about the instance's own lifecycle (a wake failure, a crash). |
| 70 | func IsAvailability(r Record) bool { |
| 71 | switch r.Source { |
| 72 | case SourceEdge: |
| 73 | return r.Kind == KindUnavailable || (r.Kind == KindHTTP && UnavailableStatus(r.Status)) |
| 74 | case SourceLog: |
| 75 | return r.Stream == applog.StreamPlatform |
| 76 | } |
| 77 | return false |
| 78 | } |
| 79 | |
| 80 | // AsAvailability regroups a record written before the amendment — an edge |
| 81 | // 502/503/504 named "<status> <method> <path>" and fingerprinted by it — |
| 82 | // under its status and an unrecorded cause, so the history in the bucket |
| 83 | // collapses the way new records do instead of staying one row per path. |
| 84 | // Every other record is returned unchanged. |
| 85 | // |
| 86 | // The overflow row is left alone: it is a count of whatever exceeded the |
| 87 | // hourly cap, not an error of its own, and keeps its FP and its label. |
| 88 | func AsAvailability(r Record) Record { |
| 89 | if r.FP == OverflowFP || r.Source != SourceEdge || r.Kind != KindHTTP || !UnavailableStatus(r.Status) { |
| 90 | return r |
| 91 | } |
| 92 | r.Kind = KindUnavailable |
| 93 | r.Name = AvailabilityName(r.Status, r.Cause) |
| 94 | r.FP = Fingerprint(SourceEdge, KindUnavailable, r.Name, "") |
| 95 | r.FPV = FPVersion |
| 96 | return r |
| 97 | } |
| 98 | |