Skip to main content

0002 — Error handling & resilience

Status: Accepted · Date: 2026-06-05

Context

Every call site handled failure ad-hoc: apiRequest threw a thin ApiError(status, message, body?) and ~10 sites each wrote their own instanceof ApiError check that leaked the raw status code to users. There was no offline awareness, no request timeout, no error boundary, no toast/alert system, no semantic error colour token (raw bg-red-50/text-red-700 everywhere), no diagnostics, and no in-app way to reach support. The backend always sends { status, reason, userMessage? } (mirroring the Swift EnodeAbort: AbortError) but userMessage was never surfaced — users saw “status 500”.

We needed one coherent, generic, table-driven resilience framework so future code and AI agents extend it by editing data tables, not by inventing patterns.

Decision

Classify every async outcome into exactly one disposition, and let the UI react to dispositions — never to raw status codes. A pure classifyError() derives the disposition from two tunable tables (status-table, action-table). Connectivity and per-store staleness are separate ambient signals feeding the same subtle-hint surfaces. A cross-cutting diagnostics substrate (breadcrumbs

  • snapshot) underlies everything and powers one-tap support reporting.

The four dispositions:

DispositionMeaningSurface
offlinetransport problem, self-healingambient banner + toast, auto-retry; never tears down
gracefulrecoverable / non-fatalinline alert (forms) or toast + retry; screen stays
redirecta known resolution path existsnavigate (e.g. 401 → re-login)
blockingagency gone, no resolutionfull-stop dialog / error boundary; always shows userMessage

Notable choices

  • Disposition is derived, not stored. Policy lives in one tunable place (packages/core/src/errors/status-table.ts), so changing how a status behaves is a one-row diff. Unknowns are deliberately graceful (never blocking).
  • apiRequest stays throwing (no Result type) and ApiError keeps its positional signature, so every existing instanceof ApiError call site is unchanged. It gained a 15 s timeout, typed NetworkError/TimeoutError wrapping, userMessage/reason parsing, connectivity signals, and HTTP breadcrumbs.
  • Offline = staleness hints, not an outbox. Reference data stays in-memory; a view shows cached data + a subtle hint when a refresh fails (canWork = hasData). A persistent write-outbox is explicitly out of scope (possible later).
  • In-house toast (no new dependency), module-store based so it can be pushed from non-React code.
  • Support reporting is vendor-neutral. The client posts a versioned, neutral report to an enode-backend relay and knows nothing about commslayer, so the helpdesk can change without breaking shipped app versions. Unhandled crashes auto-submit silently (consent-gated, deduped); handled errors and feedback are an explicit one-tap report.
  • Action-required redirects (310–313) are deferred to a later step; the mechanism (action-table, ActionRequiredError, enode:action-required) is in place and only reauth (401) is wired.

Consequences

  • Users see the backend userMessage instead of “status N”; offline is a soft, self-healing hint; crashes reach support with full context at zero user effort.
  • New code follows the cookbook in error-handling.md: every extension point is a table, registry, or seam.
  • The backend must add the relay endpoint (see the implementation prompt in the branch plan); until then setSupportTransport runs a logging no-op.