package apperr import ( "strconv" "github.com/carlosframework/platform/internal/applog" ) // Availability is the Errors tab's sibling (spec 2026-09-07 §3.2, // amendment 2026-09-24): the failures that are about whether an instance // could answer at all, not about what the request asked it to do. A 502 // on /admin/sandbox says nothing about the sandbox page; it says the // instance was down, restarting or unreachable when that page was asked // for. So these group by CAUSE, never by path: one outage is one row, and // a scanner probing forty paths of a dead instance is one row, not forty. // KindUnavailable marks an edge record filed under Availability. Records // written before the amendment carry KindHTTP and no Cause; IsAvailability // still recognises them by status, and AsAvailability regroups them. const KindUnavailable = "unavailable" // Causes the edge records for a 502/503/504, from the proxy error that // produced it (edge.proxyCause). The vocabulary is closed: an unknown // value on read renders as itself, never guessed into one of these. const ( CauseRefused = "refused" // nothing listening: the instance is down or mid-restart CauseReset = "reset" // the instance hung up mid-request CauseTimeout = "timeout" // the instance did not answer in time CauseAbandoned = "abandoned" // the visitor gave up after waiting long enough that the wait was the problem CauseNoUpstream = "no_upstream" // every upstream of a multi-upstream route is failing its probe CauseFleetDark = "fleet_dark" // no box of the route's fleet is connected CauseTunnel = "tunnel" // the tunnel to the fleet box failed CauseStatic = "static" // a static site's files could not be resolved CauseProxy = "proxy" // any other proxy failure CauseUpstream = "upstream" // the app itself answered 502/503/504 ) var causeText = map[string]string{ CauseRefused: "the instance refused the connection", CauseReset: "the instance closed the connection mid-request", CauseTimeout: "the instance did not answer in time", CauseAbandoned: "the visitor gave up waiting for the instance", CauseNoUpstream: "no healthy upstream", CauseFleetDark: "no box of the fleet is connected", CauseTunnel: "the tunnel to the fleet box failed", CauseStatic: "the static site could not be read", CauseProxy: "the proxy could not complete the request", CauseUpstream: "the app answered it itself", "": "cause not recorded", } // UnavailableStatus reports whether an edge-observed status belongs under // Availability rather than Errors. 500, 501 and 505+ stay errors: the app // answered and what it answered was a failure of the request. func UnavailableStatus(status int) bool { return status >= 502 && status <= 504 } // AvailabilityName is the row name for one (status, cause): the status and // the cause in the member's terms, and deliberately no path. func AvailabilityName(status int, cause string) string { text, ok := causeText[cause] if !ok { text = cause } return strconv.Itoa(status) + " · " + text } // IsAvailability reports whether r belongs under Availability: an edge // 502/503/504 (whichever version of the edge wrote it), or a platform // event about the instance's own lifecycle (a wake failure, a crash). func IsAvailability(r Record) bool { switch r.Source { case SourceEdge: return r.Kind == KindUnavailable || (r.Kind == KindHTTP && UnavailableStatus(r.Status)) case SourceLog: return r.Stream == applog.StreamPlatform } return false } // AsAvailability regroups a record written before the amendment — an edge // 502/503/504 named " " and fingerprinted by it — // under its status and an unrecorded cause, so the history in the bucket // collapses the way new records do instead of staying one row per path. // Every other record is returned unchanged. // // The overflow row is left alone: it is a count of whatever exceeded the // hourly cap, not an error of its own, and keeps its FP and its label. func AsAvailability(r Record) Record { if r.FP == OverflowFP || r.Source != SourceEdge || r.Kind != KindHTTP || !UnavailableStatus(r.Status) { return r } r.Kind = KindUnavailable r.Name = AvailabilityName(r.Status, r.Cause) r.FP = Fingerprint(SourceEdge, KindUnavailable, r.Name, "") r.FPV = FPVersion return r }