/** * Friendly errors — turn a raw failure record into something a human reads once * and knows what to do about. The runtime stamps machine-shaped failures * (`action.failed`, `dlq.recorded`, `external.call.failed`, …) carrying a * `SerializedError` + retry counters; this maps the common shapes to a plain * title, a one-sentence summary that names the failing unit, an actionable next * step, and a severity. Pure + data-driven so it unit-tests without a DOM and * the Errors view, the cards, and Storybook all read failures the same way. */ import type { TelemetryRecord } from "./telemetry"; /** How loud this failure should read. `critical` = the system gave up. */ export type Severity = "critical" | "high" | "warning" | "info"; /** The failure family we recognised — drives icon + copy. */ export type ErrorCategory = | "validation" | "declined" | "unauthorized" | "not-found" | "conflict" | "timeout" | "dead-letter" | "exhausted" | "external" | "downstream" | "unknown"; /** The failing unit a record points at (action / projection / call / …). */ export interface FailureSubject { readonly kind: string; readonly name: string; } /** Normalised retry state across the different failure shapes. */ export interface AttemptInfo { /** The attempt this record represents, when it carries one. */ readonly attempt?: number; /** The ceiling, when declared. */ readonly maxAttempts?: number; /** Total attempts made (DLQ / exhausted records report this). */ readonly attempts?: number; /** The runtime intends to try again. */ readonly willRetry: boolean; /** This failure ended in the dead-letter queue / exhausted retries. */ readonly terminal: boolean; } /** The humanised view of one failure record. */ export interface FriendlyError { readonly title: string; readonly summary: string; readonly suggestion: string; readonly severity: Severity; readonly category: ErrorCategory; readonly subject: FailureSubject; readonly attempt: AttemptInfo; /** The raw error message, kept for the evidence/detail surfaces. */ readonly rawMessage?: string; } const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined); /** The unit that failed — read off whichever id field the record carries. */ export function subjectOf(r: TelemetryRecord): FailureSubject { const action = str(r.action); if (action) return { kind: "action", name: action }; const projection = str(r.projection); if (projection) return { kind: "projection", name: projection }; const workflow = str(r.workflow); if (workflow) return { kind: "workflow", name: workflow }; const call = str(r.call); if (call) return { kind: "external call", name: call }; const query = str(r.query); if (query) return { kind: "query", name: query }; const listener = str(r.listener); if (listener) return { kind: "listener", name: listener }; return { kind: "operation", name: str(r.sourceEvent) ?? "an operation" }; } /** Normalise the various retry counters onto one shape. */ export function attemptOf(r: TelemetryRecord): AttemptInfo { const attempt = num(r.attempt); const attempts = num(r.attempts); const maxAttempts = num(r.maxAttempts); const terminal = r.kind === "dlq.recorded" || r.kind === "reaction.exhausted"; // `willRetry` is explicit on the retrying shapes; terminal failures never do. const willRetry = terminal ? false : r.error == null ? false : Boolean(r.willRetry); return { attempt, maxAttempts, attempts, willRetry, terminal }; } /** A short, human retry line — `attempt 2 of 3, retrying` / `3 attempts, dead-lettered`. */ export function retryNarrative(r: TelemetryRecord): string | null { const a = attemptOf(r); if (a.terminal && a.attempts) { return `${a.attempts} ${a.attempts === 1 ? "attempt" : "attempts"} — gave up`; } if (a.attempt && a.maxAttempts) { return `attempt ${a.attempt} of ${a.maxAttempts}${a.willRetry ? ", retrying" : ""}`; } if (a.attempt && a.attempt > 1) return `attempt ${a.attempt}`; return null; } interface ErrorFacts { readonly name?: string; readonly message?: string; readonly code?: string | number; } function errorFacts(r: TelemetryRecord): ErrorFacts { const e = r.error; const code = e && "code" in e ? (e.code as string | number | undefined) : undefined; return { name: e?.name, message: e?.message, code }; } const has = (haystack: string | undefined, re: RegExp): boolean => !!haystack && re.test(haystack); /** * Classify a failure into a known family. Terminal forge outcomes * (dead-letter / exhausted) own their headline regardless of the underlying * cause — "it gave up" is the story, and the cause still shows in the summary + * evidence. Otherwise read the error name/message/code (most specific), then * fall back to the remaining record kinds. */ export function categorize(r: TelemetryRecord): ErrorCategory { if (r.kind === "dlq.recorded") return "dead-letter"; if (r.kind === "reaction.exhausted") return "exhausted"; const { name, message, code } = errorFacts(r); const n = name ?? ""; const m = message ?? ""; if ( has(n, /zod|validation|invalidinput/i) || has(m, /\b(invalid|required|must be|expected|schema)\b/i) ) return "validation"; if (code === 401 || code === 403 || has(n, /unauthor|forbidden|denied/i)) return "unauthorized"; if (code === 404 || has(n, /notfound/i) || has(m, /not found|does not exist|unknown id/i)) return "not-found"; if (code === 409 || has(n, /conflict/i) || has(m, /already exists|conflict|duplicate|version/i)) return "conflict"; if ( code === 402 || has(n, /declin|rejected|paymentfailed/i) || has(m, /declin|rejected|insufficient/i) ) return "declined"; if (has(n, /timeout|aborterror/i) || has(m, /timed? ?out|timeout|deadline|aborted/i)) return "timeout"; switch (r.kind) { case "external.call.failed": return "external"; case "projection.failed": case "reaction.failed": case "event.listener.failed": case "enqueue.failed": return "downstream"; default: return "unknown"; } } interface CategoryCopy { readonly title: string; /** `(subject, message) => summary` */ readonly summary: (s: FailureSubject, m?: string) => string; readonly suggestion: string; } const COPY: Record = { validation: { title: "Invalid input", summary: (s) => `${s.name} rejected its input — the payload didn't match the schema.`, suggestion: "Check the caller's payload against the handler's `input` contract.", }, declined: { title: "Declined", summary: (s, m) => `${s.name} was declined${m ? ` — ${m}` : "."}`, suggestion: "A business rule said no. This is expected control flow, not a crash — surface it to the caller.", }, unauthorized: { title: "Not allowed", summary: (s) => `${s.name} ran without permission to do what it tried.`, suggestion: "Confirm the caller's auth / tenant scope and the handler's policy.", }, "not-found": { title: "Not found", summary: (s, m) => `${s.name} couldn't find something it needed${m ? ` — ${m}` : "."}`, suggestion: "The referenced id may be wrong, deleted, or not yet projected. Check upstream writes.", }, conflict: { title: "Conflict", summary: (s, m) => `${s.name} hit a conflicting state${m ? ` — ${m}` : "."}`, suggestion: "Likely a duplicate or a concurrent write. Retry on a fresh read, or make the op idempotent.", }, timeout: { title: "Timed out", summary: (s) => `${s.name} didn't finish in time.`, suggestion: "Check the downstream it waits on, or raise the timeout if the work is genuinely slow.", }, "dead-letter": { title: "Dead-lettered", summary: (s, m) => `${s.name} failed every retry and was parked in the dead-letter queue${m ? ` — ${m}` : "."}`, suggestion: "Inspect the payload + root error, fix the cause, then replay from the DLQ.", }, exhausted: { title: "Retries exhausted", summary: (s, m) => `${s.name} gave up after exhausting its retries${m ? ` — ${m}` : "."}`, suggestion: "The failure is persistent, not transient. Fix the underlying cause before it retries again.", }, external: { title: "External call failed", summary: (s, m) => `The external call ${s.name} failed${m ? ` — ${m}` : "."}`, suggestion: "Check the third-party endpoint's health + credentials. The fault is likely outside the app.", }, downstream: { title: "Downstream failed", summary: (s, m) => `${s.name} failed while reacting to an event${m ? ` — ${m}` : "."}`, suggestion: "A projection / reaction couldn't keep up. The read model or saga may now be behind.", }, unknown: { title: "Failed", summary: (s, m) => `${s.name} threw an unexpected error${m ? ` — ${m}` : "."}`, suggestion: "Open the evidence tab for the stack + payload, then trace the chain that led here.", }, }; const KIND_SEVERITY: Record = { "dlq.recorded": "critical", "reaction.exhausted": "critical", "projection.failed": "high", "event.listener.failed": "high", "enqueue.failed": "high", }; /** * Severity for a failure. Terminal shapes are loudest; a failure that will * still retry is only a warning (the system hasn't given up yet); validation / * declined are expected control-flow, also a warning. */ export function severityOf(r: TelemetryRecord): Severity { const a = attemptOf(r); const fixed = KIND_SEVERITY[r.kind]; if (fixed) return fixed; if (a.willRetry) return "warning"; const category = categorize(r); if (category === "validation" || category === "declined" || category === "unauthorized") return "warning"; return "high"; } const SEVERITY_RANK: Record = { info: 0, warning: 1, high: 2, critical: 3 }; /** Pick the louder of two severities. */ export function maxSeverity(a: Severity, b: Severity): Severity { return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b; } /** The full humanised view of one failure record. */ export function explainError(r: TelemetryRecord): FriendlyError { const category = categorize(r); const subject = subjectOf(r); const facts = errorFacts(r); const copy = COPY[category]; return { title: copy.title, summary: copy.summary(subject, facts.message), suggestion: copy.suggestion, severity: severityOf(r), category, subject, attempt: attemptOf(r), rawMessage: facts.message, }; }