carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package apperr
2
3import (
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.
20const 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.
25const (
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
38var 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.
55func 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.
59func 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).
70func 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.
88func 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