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.