flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/status/src/probe.ts

146 lines5,421 bytesCodeBlame
1/**
2 * Running a part's check: each request it makes, all at once, with a short
3 * timeout. The answer's body is never read.
4 */
5import type { Check, Step } from "./components.ts";
6
7/** A check's raw outcome. */
8export type ProbeResult = {
9 ok: boolean;
10 /** How long it took, in milliseconds: the slowest of its requests. */
11 ms: number;
12 /** Why it failed, in a few words: "timed out", "HTTP 502". */
13 error?: string;
14 /** Why it worked but not well, when that is not just slowness. */
15 degraded?: string;
16};
17
18/** How one git store namespace answered lately: repos `store_health`. */
19export type StoreHealthRow = {
20 store: string;
21 calls: number;
22 errors: number;
23 rate_limited: number;
24 rejected: number;
25 ms_total: number;
26};
27
28export type StorageReport = { minutes: number; stores: StoreHealthRow[] };
29
30/** At least this many failed calls, and this share of them, before git storage is down. */
31const STORAGE_MIN_ERRORS = 5;
32const STORAGE_DOWN_SHARE = 0.25;
33
34/**
35 * What the git store's recent answers mean: down when a quarter or more of
36 * its calls failed (at least five), or calls were refused after repeated
37 * failures; degraded when it rate limited g1t; otherwise as fast as its
38 * mean call. Quiet is up.
39 */
40export function judgeStorage(report: StorageReport): ProbeResult {
41 const sum = (key: keyof Omit<StoreHealthRow, "store">) =>
42 report.stores.reduce((total, row) => total + (Number(row[key]) || 0), 0);
43 const calls = sum("calls");
44 const errors = sum("errors");
45 const limited = sum("rate_limited");
46 const rejected = sum("rejected");
47 const ms = calls > 0 ? sum("ms_total") / calls : 0;
48 if (rejected > 0) return { ok: false, ms, error: `calls refused after repeated failures (${rejected})` };
49 if (errors >= STORAGE_MIN_ERRORS && errors / Math.max(calls, 1) >= STORAGE_DOWN_SHARE) {
50 return { ok: false, ms, error: `${Math.round((100 * errors) / calls)}% of calls failed` };
51 }
52 if (limited > 0) return { ok: true, ms, degraded: `Rate limited ${limited} times in ${report.minutes} minutes` };
53 return { ok: true, ms };
54}
55
56/** No request waits longer than this. */
57export const TIMEOUT_MS = 5000;
58
59export const USER_AGENT = "g1t-status (+https://status.g1t.sh)";
60
61type Fetch = (url: string, init: RequestInit) => Promise<Response>;
62
63class Timeout extends Error {}
64
65/** Runs `work`, timing it, and failing it after `timeoutMs`. */
66export async function timed(
67 work: (signal: AbortSignal) => Promise<true | string>,
68 timeoutMs = TIMEOUT_MS,
69 now: () => number = Date.now,
70): Promise<ProbeResult> {
71 const started = now();
72 const controller = new AbortController();
73 let timer: ReturnType<typeof setTimeout> | undefined;
74 const deadline = new Promise<never>((_, reject) => {
75 timer = setTimeout(() => {
76 controller.abort();
77 reject(new Timeout());
78 }, timeoutMs);
79 });
80 try {
81 const outcome = await Promise.race([work(controller.signal), deadline]);
82 const ms = now() - started;
83 return outcome === true ? { ok: true, ms } : { ok: false, ms, error: outcome };
84 } catch (error) {
85 const ms = now() - started;
86 return { ok: false, ms, error: error instanceof Timeout ? "timed out" : "could not connect" };
87 } finally {
88 clearTimeout(timer);
89 }
90}
91
92/** One request, answered with the status that means it works. */
93export function step(fetcher: Fetch, { url, headers = {}, expect }: Step, timeoutMs = TIMEOUT_MS): Promise<ProbeResult> {
94 return timed(async (signal) => {
95 const response = await fetcher(url, {
96 signal,
97 redirect: "manual",
98 headers: { "user-agent": USER_AGENT, "cache-control": "no-cache", ...headers },
99 });
100 await response.body?.cancel().catch(() => undefined);
101 const good = expect == null ? response.ok : response.status === expect;
102 return good || `HTTP ${response.status}`;
103 }, timeoutMs);
104}
105
106/** A check's requests together: it works when every one does, and takes as long as the slowest. */
107export function combine(results: ProbeResult[]): ProbeResult {
108 const ms = Math.max(0, ...results.map((r) => r.ms));
109 const failed = results.find((r) => !r.ok);
110 return failed ? { ok: false, ms, error: failed.error ?? "no answer" } : { ok: true, ms };
111}
112
113/** What runs a check. `billing` is null when there is no binding to it. */
114export type Probers = {
115 fetch: Fetch;
116 billing: (() => Promise<unknown>) | null;
117 /** Git storage's recent health, through the repos service; null when not bound. */
118 storage?: (() => Promise<StorageReport>) | null;
119 timeoutMs?: number;
120};
121
122/** Runs one part's check. Null for a part with no check. */
123export async function runCheck(check: Check, probers: Probers): Promise<ProbeResult | null> {
124 const timeoutMs = probers.timeoutMs ?? TIMEOUT_MS;
125 switch (check.kind) {
126 case "http":
127 return combine(await Promise.all(check.steps.map((s) => step(probers.fetch, s, timeoutMs))));
128 case "billing": {
129 const billing = probers.billing;
130 if (!billing) return null;
131 return timed(async () => ((await billing()) ? true : "no price book"), timeoutMs);
132 }
133 case "storage": {
134 const storage = probers.storage;
135 if (!storage) return null;
136 let report: StorageReport | null = null;
137 const asked = await timed(async () => {
138 report = await storage();
139 return true;
140 }, timeoutMs);
141 return report ? judgeStorage(report) : asked;
142 }
143 case "none":
144 return null;
145 }
146}