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:
| Disposition | Meaning | Surface |
|---|---|---|
offline | transport problem, self-healing | ambient banner + toast, auto-retry; never tears down |
graceful | recoverable / non-fatal | inline alert (forms) or toast + retry; screen stays |
redirect | a known resolution path exists | navigate (e.g. 401 → re-login) |
blocking | agency gone, no resolution | full-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 deliberatelygraceful(neverblocking). apiRequeststays throwing (noResulttype) andApiErrorkeeps its positional signature, so every existinginstanceof ApiErrorcall site is unchanged. It gained a 15 s timeout, typedNetworkError/TimeoutErrorwrapping,userMessage/reasonparsing, 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 onlyreauth(401) is wired.
Consequences
- Users see the backend
userMessageinstead 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
setSupportTransportruns a logging no-op.