@remit/doctor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/verdict.ts ADDED
@@ -0,0 +1,386 @@
1
+ import type { HeartbeatReading } from "./heartbeats.js";
2
+ import { seriesNamed } from "./prometheus.js";
3
+ import type { ScrapeResult } from "./scrape.js";
4
+ import type { CounterState } from "./state.js";
5
+
6
+ export type Verdict = "healthy" | "degraded";
7
+
8
+ export type ReasonCode =
9
+ | "scrape_failed"
10
+ | "worker_heartbeat_stale"
11
+ | "dead_letter_queue_not_empty"
12
+ | "account_sync_stalled"
13
+ | "mail_auth_failing"
14
+ | "signal_missing";
15
+
16
+ /**
17
+ * One thing that is wrong.
18
+ *
19
+ * `summary` carries counts, service names and queue names, and nothing else —
20
+ * D10. It is the only field a webhook payload ever reads, so the rule is a
21
+ * property of the type rather than a habit of the renderer: an address, a
22
+ * subject or an account id in a summary would leave the box, and there is no
23
+ * path by which `detail` can.
24
+ *
25
+ * `detail` is what the operator needs at a shell on the box to act on the
26
+ * reason — the account ids behind "2 of 5 accounts". It is printed by the exec
27
+ * seam and never sent anywhere.
28
+ *
29
+ * The boundary is enforced by a test, not by the type: `verdict.test.ts` runs
30
+ * every reason over a scrape whose `account_id`, `folder` and `mailbox` labels
31
+ * are a sentinel and asserts no summary contains it. That is a tripwire, not a
32
+ * guarantee — it cannot catch a NEW reason derived from a series the fixture
33
+ * does not carry, and the `deepEqual` on the reason-code set only makes such a
34
+ * reason noticeable, not safe.
35
+ *
36
+ * The thing that would actually scale this to reasons nobody has written yet is
37
+ * a branded `Summary` type that only a sanitising constructor can produce, so
38
+ * interpolating a raw label into one is a compile error rather than a review
39
+ * catch. If you are adding a reason and reaching for a label value here, that is
40
+ * the change to make first.
41
+ */
42
+ export interface Reason {
43
+ readonly code: ReasonCode;
44
+ readonly summary: string;
45
+ readonly detail: string | undefined;
46
+ }
47
+
48
+ export interface CheckResult {
49
+ readonly verdict: Verdict;
50
+ readonly checkedAt: string;
51
+ readonly summary: string;
52
+ readonly reasons: readonly Reason[];
53
+ /**
54
+ * Counter totals to compare the next check against. Carried forward
55
+ * unchanged for any service that did not answer this time, so a scrape
56
+ * failure cannot manufacture a delta when the service comes back.
57
+ */
58
+ readonly counters: Readonly<Record<string, CounterState>>;
59
+ }
60
+
61
+ export interface VerdictThresholds {
62
+ readonly heartbeatMaxAgeSeconds: number;
63
+ readonly syncAgeMaxSeconds: number;
64
+ readonly authFailureHoldSeconds: number;
65
+ }
66
+
67
+ export interface VerdictInput extends VerdictThresholds {
68
+ readonly scrapes: readonly ScrapeResult[];
69
+ readonly heartbeats: readonly HeartbeatReading[];
70
+ readonly previousCounters: Readonly<Record<string, CounterState>>;
71
+ readonly now: Date;
72
+ }
73
+
74
+ const IMAP_AUTH = "imap-worker:imap_auth_failures";
75
+ const SMTP_AUTH = "smtp-worker:smtp_auth_failures";
76
+
77
+ export const formatDuration = (seconds: number): string => {
78
+ if (seconds >= 3600) {
79
+ const hours = seconds / 3600;
80
+ return `${Number.isInteger(hours) ? hours : hours.toFixed(1)}h`;
81
+ }
82
+ if (seconds >= 60) {
83
+ const minutes = seconds / 60;
84
+ return `${Number.isInteger(minutes) ? minutes : minutes.toFixed(1)}m`;
85
+ }
86
+ return `${seconds}s`;
87
+ };
88
+
89
+ const plural = (count: number, one: string, many: string): string =>
90
+ count === 1 ? one : many;
91
+
92
+ /**
93
+ * "1 of 4 services is not answering". The noun agrees with the total and the
94
+ * verb with the affected count, which is the only combination that reads as
95
+ * English at every pair of numbers.
96
+ */
97
+ const outOf = (
98
+ affected: number,
99
+ total: number,
100
+ noun: readonly [string, string],
101
+ verb: readonly [string, string],
102
+ ): string =>
103
+ `${affected} of ${total} ${plural(total, noun[0], noun[1])} ${plural(affected, verb[0], verb[1])}`;
104
+
105
+ const scrapeFailures = (
106
+ scrapes: readonly ScrapeResult[],
107
+ ): Reason | undefined => {
108
+ const failed = scrapes.filter((scrape) => scrape.error !== undefined);
109
+ if (failed.length === 0) return undefined;
110
+ const names = failed.map((scrape) => scrape.service).join(", ");
111
+ return {
112
+ code: "scrape_failed",
113
+ summary: `${outOf(failed.length, scrapes.length, ["service", "services"], ["is", "are"])} not answering /metrics (${names})`,
114
+ detail: failed
115
+ .map((scrape) => `${scrape.service}: ${scrape.error}`)
116
+ .join("; "),
117
+ };
118
+ };
119
+
120
+ const staleHeartbeats = (
121
+ heartbeats: readonly HeartbeatReading[],
122
+ maxAgeSeconds: number,
123
+ ): Reason | undefined => {
124
+ const stale = heartbeats.filter(
125
+ (reading) =>
126
+ reading.ageSeconds === undefined || reading.ageSeconds > maxAgeSeconds,
127
+ );
128
+ if (stale.length === 0) return undefined;
129
+ const names = stale.map((reading) => reading.service).join(", ");
130
+ return {
131
+ code: "worker_heartbeat_stale",
132
+ summary: `${outOf(stale.length, heartbeats.length, ["worker", "workers"], ["has", "have"])} stopped polling for over ${formatDuration(maxAgeSeconds)} (${names})`,
133
+ detail: stale
134
+ .map((reading) =>
135
+ reading.ageSeconds === undefined
136
+ ? `${reading.service}: ${reading.error}`
137
+ : `${reading.service}: ${Math.round(reading.ageSeconds)}s`,
138
+ )
139
+ .join("; "),
140
+ };
141
+ };
142
+
143
+ const deadLetterDepth = (
144
+ samples: readonly ScrapeResult[],
145
+ ): Reason | undefined => {
146
+ const all = samples.flatMap((scrape) => [...scrape.samples]);
147
+ const occupied = seriesNamed(all, "remit_queue_messages").filter(
148
+ (sample) => sample.labels.role === "dead_letter" && sample.value > 0,
149
+ );
150
+ if (occupied.length === 0) return undefined;
151
+ const total = occupied.reduce((sum, sample) => sum + sample.value, 0);
152
+ const names = occupied
153
+ .map((sample) => sample.labels.queue ?? "unknown")
154
+ .sort()
155
+ .join(", ");
156
+ return {
157
+ code: "dead_letter_queue_not_empty",
158
+ summary: `${total} ${plural(total, "message is", "messages are")} quarantined on ${occupied.length} ${plural(occupied.length, "dead-letter queue", "dead-letter queues")} (${names})`,
159
+ detail: undefined,
160
+ };
161
+ };
162
+
163
+ const stalledSync = (
164
+ samples: readonly ScrapeResult[],
165
+ maxAgeSeconds: number,
166
+ ): Reason | undefined => {
167
+ const all = samples.flatMap((scrape) => [...scrape.samples]);
168
+ const ages = seriesNamed(all, "remit_account_sync_age_seconds");
169
+ const stalled = ages.filter((sample) => sample.value > maxAgeSeconds);
170
+ if (stalled.length === 0) return undefined;
171
+ return {
172
+ code: "account_sync_stalled",
173
+ // Counts only. Which accounts is on the box, behind `remit doctor`.
174
+ summary: `${outOf(stalled.length, ages.length, ["account", "accounts"], ["has", "have"])} not completed a sync in over ${formatDuration(maxAgeSeconds)}`,
175
+ detail: stalled
176
+ .map(
177
+ (sample) =>
178
+ `${sample.labels.account_id ?? "unknown"}: ${Math.round(sample.value)}s`,
179
+ )
180
+ .sort()
181
+ .join("; "),
182
+ };
183
+ };
184
+
185
+ interface CounterReading {
186
+ readonly key: string;
187
+ /** `undefined` when the exporting service did not answer this check. */
188
+ readonly state: CounterState | undefined;
189
+ /** How much it went up by, when this check is the one that saw it rise. */
190
+ readonly delta: number | undefined;
191
+ }
192
+
193
+ /**
194
+ * Authentication failures are counters, and a counter that has been non-zero
195
+ * since March is not news — an alert on the total fires forever after one
196
+ * expired grant. The increase since the previous check is the one piece of
197
+ * history a checker with a state volume can hold without a time-series
198
+ * database.
199
+ *
200
+ * That increase is true for exactly one check, which is not long enough to
201
+ * satisfy a dwell, so `lastRoseAt` turns the instant into a condition: the
202
+ * signal stays on until the counter has stopped rising for the hold window.
203
+ * Failures arrive in one burst per sync tick, so the quiet stretch between two
204
+ * bursts is not a recovery and must not read as one.
205
+ *
206
+ * A service that did not answer has no reading: its previous state is carried
207
+ * forward untouched, so the delta on its return is measured against what it
208
+ * last really exported rather than against a zero nobody observed — and its
209
+ * `lastRoseAt` keeps holding the condition open across the outage.
210
+ *
211
+ * A total below the previous one is the exporter having restarted, not work
212
+ * being undone, so the whole current total counts as new.
213
+ */
214
+ const readCounter = (
215
+ scrapes: readonly ScrapeResult[],
216
+ key: string,
217
+ service: string,
218
+ metric: string,
219
+ previous: Readonly<Record<string, CounterState>>,
220
+ now: number,
221
+ ): CounterReading => {
222
+ const before = previous[key];
223
+ const scrape = scrapes.find((candidate) => candidate.service === service);
224
+ if (scrape === undefined || scrape.error !== undefined) {
225
+ return { key, state: before, delta: undefined };
226
+ }
227
+ const total = seriesNamed(scrape.samples, metric)
228
+ .filter((sample) => sample.labels.kind === "auth")
229
+ .reduce((sum, sample) => sum + sample.value, 0);
230
+ if (before === undefined) {
231
+ // First sight of the counter is a baseline, not an event. Whatever it
232
+ // already holds happened before this checker was watching.
233
+ return { key, state: { total, lastRoseAt: null }, delta: undefined };
234
+ }
235
+ const delta = total < before.total ? total : total - before.total;
236
+ if (delta <= 0) return { key, state: { ...before, total }, delta: undefined };
237
+ return { key, state: { total, lastRoseAt: now }, delta };
238
+ };
239
+
240
+ const PROTOCOL: Readonly<Record<string, string>> = {
241
+ [IMAP_AUTH]: "IMAP",
242
+ [SMTP_AUTH]: "SMTP",
243
+ };
244
+
245
+ /**
246
+ * Failing while the counter is still rising, and for `holdSeconds` after the
247
+ * last rise. The summary says how long ago rather than how many, because the
248
+ * count is an artefact of the retry cadence and the age is the fact the
249
+ * operator acts on.
250
+ */
251
+ const authFailures = (
252
+ readings: readonly CounterReading[],
253
+ holdSeconds: number,
254
+ now: number,
255
+ ): Reason | undefined => {
256
+ const failing = readings.filter((reading) => {
257
+ const rose = reading.state?.lastRoseAt;
258
+ return (
259
+ rose !== undefined && rose !== null && now - rose <= holdSeconds * 1000
260
+ );
261
+ });
262
+ if (failing.length === 0) return undefined;
263
+ const parts = failing.map((reading) => {
264
+ const since = Math.max(0, now - (reading.state?.lastRoseAt ?? now)) / 1000;
265
+ return `${PROTOCOL[reading.key] ?? "mail"} (last failure ${formatDuration(Math.round(since))} ago)`;
266
+ });
267
+ return {
268
+ code: "mail_auth_failing",
269
+ // No address and no account id: the counters carry neither, and the
270
+ // operator identifies the mailbox by running `remit doctor` on the box.
271
+ summary: `mail authentication is failing: ${parts.join(", ")}`,
272
+ detail: undefined,
273
+ };
274
+ };
275
+
276
+ /**
277
+ * A 200 that carries no samples is not a healthy service. A metric rename, a
278
+ * collector that starts returning nothing instead of throwing, or a target on
279
+ * the wrong port that happens to answer 200 would all render as "nothing
280
+ * wrong", which is the `healthy`-produced-by-a-check-that-failed-to-look that
281
+ * D4 rules out.
282
+ *
283
+ * Only `remit_queue_messages` is required. It is the one series a working
284
+ * deployment always exports — the queue set is declared in `queues.json` and
285
+ * the sidecar renders a sample per queue whether or not anything is on it.
286
+ * `remit_account_sync_age_seconds` is legitimately empty on a fresh install
287
+ * with no mailbox yet, and the auth counters do not exist until something has
288
+ * failed once, so neither can be required without alerting on a healthy
289
+ * install.
290
+ */
291
+ const REQUIRED_SERIES: readonly { service: string; metric: string }[] = [
292
+ { service: "queue", metric: "remit_queue_messages" },
293
+ ];
294
+
295
+ const missingSeries = (
296
+ scrapes: readonly ScrapeResult[],
297
+ ): Reason | undefined => {
298
+ const missing = REQUIRED_SERIES.flatMap(({ service, metric }) => {
299
+ const scrape = scrapes.find((candidate) => candidate.service === service);
300
+ // Not configured at all is missing, not fine. A `DOCTOR_TARGETS` with no
301
+ // queue endpoint would otherwise read healthy with the dead-letter signal
302
+ // silently gone — the epic's headline check failing open, and the one
303
+ // failure mode with no symptom.
304
+ if (scrape === undefined) {
305
+ return [{ service, why: `${metric} has no configured target` }];
306
+ }
307
+ // A target that did not answer is already `scrape_failed`. Saying it twice
308
+ // tells the operator nothing and costs a line in the alert.
309
+ if (scrape.error !== undefined) return [];
310
+ return seriesNamed(scrape.samples, metric).length === 0
311
+ ? [{ service, why: `${metric} absent from the response` }]
312
+ : [];
313
+ });
314
+ if (missing.length === 0) return undefined;
315
+ return {
316
+ code: "signal_missing",
317
+ summary: `${missing.length} ${plural(missing.length, "signal", "signals")} the check depends on ${plural(missing.length, "is", "are")} not being read (${missing.map(({ service }) => service).join(", ")})`,
318
+ detail: missing.map(({ service, why }) => `${service}: ${why}`).join("; "),
319
+ };
320
+ };
321
+
322
+ const ORDER: readonly ReasonCode[] = [
323
+ "scrape_failed",
324
+ "signal_missing",
325
+ "worker_heartbeat_stale",
326
+ "account_sync_stalled",
327
+ "mail_auth_failing",
328
+ "dead_letter_queue_not_empty",
329
+ ];
330
+
331
+ /**
332
+ * The verdict, from the signals as read. Pure: every input is a value, so the
333
+ * loop, the exec seam and the tests all evaluate the same function.
334
+ *
335
+ * A signal that could not be evaluated is `degraded`, never skipped — a
336
+ * `healthy` produced by a check that failed to look is the worst outcome
337
+ * available.
338
+ */
339
+ export const evaluate = (input: VerdictInput): CheckResult => {
340
+ const now = input.now.getTime();
341
+ const counters = [
342
+ readCounter(
343
+ input.scrapes,
344
+ IMAP_AUTH,
345
+ "imap-worker",
346
+ "remit_imap_failures_total",
347
+ input.previousCounters,
348
+ now,
349
+ ),
350
+ readCounter(
351
+ input.scrapes,
352
+ SMTP_AUTH,
353
+ "smtp-worker",
354
+ "remit_smtp_failures_total",
355
+ input.previousCounters,
356
+ now,
357
+ ),
358
+ ];
359
+
360
+ const found = [
361
+ scrapeFailures(input.scrapes),
362
+ missingSeries(input.scrapes),
363
+ staleHeartbeats(input.heartbeats, input.heartbeatMaxAgeSeconds),
364
+ stalledSync(input.scrapes, input.syncAgeMaxSeconds),
365
+ authFailures(counters, input.authFailureHoldSeconds, now),
366
+ deadLetterDepth(input.scrapes),
367
+ ].filter((reason): reason is Reason => reason !== undefined);
368
+
369
+ const reasons = [...found].sort(
370
+ (left, right) => ORDER.indexOf(left.code) - ORDER.indexOf(right.code),
371
+ );
372
+
373
+ const nextCounters = { ...input.previousCounters };
374
+ for (const reading of counters) {
375
+ if (reading.state !== undefined) nextCounters[reading.key] = reading.state;
376
+ }
377
+
378
+ const verdict: Verdict = reasons.length === 0 ? "healthy" : "degraded";
379
+ return {
380
+ verdict,
381
+ checkedAt: input.now.toISOString(),
382
+ summary: verdict === "healthy" ? "remit is healthy" : "remit is degraded",
383
+ reasons,
384
+ counters: nextCounters,
385
+ };
386
+ };
@@ -0,0 +1,241 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { CheckResult, Reason } from "./verdict.js";
4
+ import {
5
+ buildBody,
6
+ defaultTemplate,
7
+ escapeFor,
8
+ expandTemplate,
9
+ payloadValues,
10
+ postWebhook,
11
+ render,
12
+ } from "./webhook.js";
13
+
14
+ const result = (
15
+ verdict: "healthy" | "degraded",
16
+ reasons: readonly Reason[] = [],
17
+ ): CheckResult => ({
18
+ verdict,
19
+ checkedAt: "2026-07-27T10:00:00.000Z",
20
+ summary: verdict === "healthy" ? "remit is healthy" : "remit is degraded",
21
+ reasons,
22
+ counters: {},
23
+ });
24
+
25
+ const reason = (summary: string, detail?: string): Reason => ({
26
+ code: "dead_letter_queue_not_empty",
27
+ summary,
28
+ detail,
29
+ });
30
+
31
+ describe("the default templates", () => {
32
+ it("produces valid JSON Slack accepts, for a real reason set", () => {
33
+ const body = buildBody(
34
+ result("degraded", [
35
+ reason(
36
+ "3 messages are quarantined on 1 dead-letter queue (imap-sync-dlq)",
37
+ ),
38
+ reason("2 of 5 accounts have not completed a sync in over 3h"),
39
+ ]),
40
+ undefined,
41
+ "application/json",
42
+ );
43
+ const parsed = JSON.parse(body) as { text: string };
44
+ assert.match(parsed.text, /^remit is degraded\n/);
45
+ assert.match(parsed.text, /• 3 messages are quarantined/);
46
+ assert.match(parsed.text, /• 2 of 5 accounts/);
47
+ });
48
+
49
+ it("produces a plain body for ntfy, with real newlines", () => {
50
+ const body = buildBody(
51
+ result("degraded", [
52
+ reason("1 worker has stopped polling (imap-worker)"),
53
+ ]),
54
+ undefined,
55
+ "text/plain",
56
+ );
57
+ assert.equal(
58
+ body,
59
+ "remit is degraded\n• 1 worker has stopped polling (imap-worker)",
60
+ );
61
+ });
62
+
63
+ it("says so plainly when a recovery has nothing to list", () => {
64
+ const body = buildBody(result("healthy"), undefined, "text/plain");
65
+ assert.equal(body, "remit is healthy\nno problems found");
66
+ });
67
+ });
68
+
69
+ describe("escaping", () => {
70
+ it("keeps a quote, a backslash and a newline from breaking the JSON document", () => {
71
+ const body = buildBody(
72
+ result("degraded", [reason('queue "odd\\name"\nsecond line')]),
73
+ undefined,
74
+ "application/json",
75
+ );
76
+ const parsed = JSON.parse(body) as { text: string };
77
+ assert.match(parsed.text, /queue "odd\\name"/);
78
+ assert.match(parsed.text, /\nsecond line/);
79
+ });
80
+
81
+ it("survives a control character", () => {
82
+ const body = buildBody(
83
+ result("degraded", [reason("tab\there and a bell")]),
84
+ undefined,
85
+ "application/json",
86
+ );
87
+ assert.doesNotThrow(() => JSON.parse(body));
88
+ });
89
+
90
+ it("leaves a plain-text body exactly as it reads", () => {
91
+ const body = buildBody(
92
+ result("degraded", [reason('a "quoted" thing')]),
93
+ undefined,
94
+ "text/plain",
95
+ );
96
+ assert.match(body, /a "quoted" thing/);
97
+ });
98
+
99
+ it("escapes for a charset-qualified JSON content type too", () => {
100
+ assert.equal(escapeFor("application/json; charset=utf-8")('"'), '\\"');
101
+ assert.equal(escapeFor("text/plain; charset=utf-8")('"'), '"');
102
+ });
103
+ });
104
+
105
+ describe("operator templates", () => {
106
+ it("substitutes the three documented placeholders", () => {
107
+ const body = buildBody(
108
+ result("degraded", [reason("one thing")]),
109
+ '{"title":"{{verdict}}","body":"{{summary}} / {{reasons}}"}',
110
+ "application/json",
111
+ );
112
+ const parsed = JSON.parse(body) as { title: string; body: string };
113
+ assert.equal(parsed.title, "degraded");
114
+ assert.equal(parsed.body, "remit is degraded / • one thing");
115
+ });
116
+
117
+ it("leaves anything else in braces alone", () => {
118
+ assert.equal(
119
+ render(
120
+ "{{summary}} {{unknown}} {not a placeholder}",
121
+ { verdict: "healthy", summary: "s", reasons: "r" },
122
+ (value) => value,
123
+ ),
124
+ "s {{unknown}} {not a placeholder}",
125
+ );
126
+ });
127
+
128
+ it("turns a backslash-n in a plain-text template into a newline, since .env cannot carry one", () => {
129
+ assert.equal(expandTemplate("a\\nb", "text/plain"), "a\nb");
130
+ assert.equal(expandTemplate("a\\tb", "text/plain"), "a\tb");
131
+ });
132
+
133
+ it("leaves a JSON template's own escapes alone", () => {
134
+ assert.equal(
135
+ expandTemplate('{"t":"a\\nb"}', "application/json"),
136
+ '{"t":"a\\nb"}',
137
+ );
138
+ });
139
+
140
+ it("defaults by content type", () => {
141
+ assert.match(defaultTemplate("application/json"), /^\{"text"/);
142
+ assert.equal(defaultTemplate("text/plain"), "{{summary}}\n{{reasons}}");
143
+ });
144
+ });
145
+
146
+ describe("what a payload may carry", () => {
147
+ it("never reaches a reason's local-only detail", () => {
148
+ const values = payloadValues(
149
+ result("degraded", [
150
+ reason(
151
+ "1 of 3 accounts have not completed a sync in over 3h",
152
+ "0f8a-secret: 40000s",
153
+ ),
154
+ ]),
155
+ );
156
+ for (const value of Object.values(values)) {
157
+ assert.ok(!value.includes("0f8a-secret"));
158
+ }
159
+ });
160
+
161
+ it("has exactly three fields, so nothing new can leak in by accident", () => {
162
+ assert.deepEqual(Object.keys(payloadValues(result("healthy"))).sort(), [
163
+ "reasons",
164
+ "summary",
165
+ "verdict",
166
+ ]);
167
+ });
168
+ });
169
+
170
+ describe("postWebhook", () => {
171
+ it("posts the rendered body with the declared content type", async () => {
172
+ let seen: { url: string; init: RequestInit } | undefined;
173
+ const outcome = await postWebhook(
174
+ {
175
+ url: "https://hooks.example/x",
176
+ template: undefined,
177
+ contentType: "application/json",
178
+ timeoutMs: 1000,
179
+ },
180
+ result("healthy"),
181
+ (async (url: string, init: RequestInit) => {
182
+ seen = { url, init };
183
+ return new Response("ok", { status: 200 });
184
+ }) as unknown as typeof fetch,
185
+ );
186
+ assert.equal(seen?.url, "https://hooks.example/x");
187
+ assert.equal(seen?.init.method, "POST");
188
+ assert.deepEqual(seen?.init.headers, {
189
+ "content-type": "application/json",
190
+ });
191
+ assert.doesNotThrow(() => JSON.parse(String(seen?.init.body)));
192
+ assert.deepEqual(outcome, { kind: "sent" });
193
+ });
194
+
195
+ const deliver = (responder: () => Promise<Response>) =>
196
+ postWebhook(
197
+ {
198
+ url: "https://hooks.example/x",
199
+ template: undefined,
200
+ contentType: "application/json",
201
+ timeoutMs: 1000,
202
+ },
203
+ result("healthy"),
204
+ responder as unknown as typeof fetch,
205
+ );
206
+
207
+ // The split that decides whether a transition is spent or retried. A wrong
208
+ // answer either way is a real cost: retrying a 4xx forever, or losing an
209
+ // outage alert to one transient 503.
210
+ it("reads a 4xx as the endpoint refusing this payload", async () => {
211
+ for (const status of [400, 401, 403, 404, 410, 422]) {
212
+ assert.deepEqual(
213
+ await deliver(async () => new Response("no", { status })),
214
+ { kind: "rejected", detail: `HTTP ${status}` },
215
+ );
216
+ }
217
+ });
218
+
219
+ it("reads a 5xx, a 429 and a transport failure as never having arrived", async () => {
220
+ for (const status of [500, 502, 503, 504, 429]) {
221
+ assert.deepEqual(
222
+ await deliver(async () => new Response("", { status })),
223
+ { kind: "unreachable", detail: `HTTP ${status}` },
224
+ );
225
+ }
226
+ assert.deepEqual(
227
+ await deliver(async () => {
228
+ throw new Error("socket hang up");
229
+ }),
230
+ { kind: "unreachable", detail: "socket hang up" },
231
+ );
232
+ });
233
+
234
+ it("never throws, so the loop always reaches the dead-man ping", async () => {
235
+ await assert.doesNotReject(
236
+ deliver(async () => {
237
+ throw new Error("boom");
238
+ }),
239
+ );
240
+ });
241
+ });