Skip to main content

Error handling

The app routes every async failure through one error engine in @enode/core. The UI reacts to a disposition, never to a raw status code. This page is the mental model plus a cookbook so new code (and AI agents) extend the system by editing tables and seams, not by inventing patterns. See ADR 0002 for the rationale.

This page covers failures. For when to proactively confirm an outcome — success toasts, and how failures map onto toast vs inline vs dialog — see user feedback (toasts & confirmation).

Where the code lives

Error handling spans the three layers by design — pure logic in @enode/core, presentational surfaces in @enode/ui, app wiring in @enode/tracking:

LayerLocationWhat's there
Logic (@enode/core)errors/the engine: classify, status-table, action-table, action-resolver, error types, the report/handler/resolver seams
diagnostics/, connectivity/, store-kit/breadcrumbs + crash capture, the online/offline store, the load-status (RequireData) backbone
api/client.tsapiRequest (throws + classifies + resumes redirects) and rawApiRequest
Surfaces (@enode/ui)feedback/InlineAlert, Toaster/toast, BlockingErrorDialog, StalenessHint, the support-report sheet, RequireData
App wiring (@enode/tracking)app/error-handling/ErrorEngineInit (registers the seams at startup), use-error-presenter (toast/inline/dialog), OfflineBanner, and redirects/ (the 310–313 resolution dialogs + stores)
app/error.tsx, app/global-error.tsxthe route + root error boundaries (Next requires these at the app root, so they stay there)
app/debug/errors/the dev screen that triggers every state by hand

The portal (@enode/portal) mirrors the app-wiring layer with its own slim app/error-handling/ErrorEngineInit.tsx (no SSE escalation) and its own app/error.tsx / app/global-error.tsx boundaries — the segment boundary renders through the shared FullStopState.

The four dispositions

A pure classifyError(error) (packages/core/src/errors/classify.ts) maps any thrown value to one ErrorDisposition:

DispositionWhenSurface
offlineoffline / DNS / timeout / 408ambient OfflineBanner + toast, auto-retry; never tears down
graceful400/403/404/409/422/429, 5xx, unknownInlineAlert (forms) or toast + retry; screen stays
redirect401 → re-login; 310/311/312 → a resolution dialoga guard navigates (401) or a dialog resolves + the request resumes (310–312)
blockingunrecoverable / escalated critical writeBlockingErrorDialog / error boundary; always shows userMessage

Two ambient signals are not dispositions: connectivity (online/offline) and per-store staleness — see offline & sync. They escalate to a real error only when a view has no usable data at all.

Policy lives in tables

  • status-table.ts — HTTP status → { disposition, retryable }. The single source of HTTP policy. Change a row to change behaviour; tests read the table.
  • action-table.ts — HTTP status → a RequiredAction (e.g. 401 → reauth). Checked before the status table.

The error objects carry facts (status, reason, userMessage, kind); the classifier derives the disposition. The backend userMessage is the only string shown to users — reason is developer-facing and never displayed.

Resumable redirects (310/311/312)

The backend's Enode-specific redirects are an interruption, not a failure: the request pauses, a resolution dialog appears, and once resolved the original request resumes (mirrors the iOS ENDialog flow — these mostly fire at login).

  • apiRequest (api/client.ts): when actionForError(status) returns a resumable action (310 → resolve-device-limit, 311 → complete-onboarding, 312 → resolve-invitations), it awaits the resolver seam resolveRequiredAction(...) (errors/action-resolver.ts) and, on "resolved", retries the same request (execute(path, options, attempt+1), capped at MAX_ACTION_RETRIES). "cancelled" / no resolver → throws ActionRequiredError. 401 is unchanged (destructive logout via AuthGuard); 313 stays unmapped (the definition-editing layer owns it, which the web app doesn't have yet).
  • UI: app/error-handling/redirects/ActionResolverInit.tsx registers the resolver → action-dialog-store.ts (queues/coalesces) → ActionDialogHost.tsx renders the dialog for the kind: DeviceLimitDialog (remove a session via removeDeviceSession + the body's sessionRemoveToken), InvitationsDialog (confirmInvitations; unselected = auto-rejected), OnboardingStubDialog (311 placeholder — no onboarding screen yet, so it cancels). Resolving settles the store promise, which unblocks apiRequest.
  • New apiRequest option bearerToken sends a one-off token (the sessionRemoveToken) instead of the stored session token.
  • Try it: /debug/errors → fire 310/311/312 → "Resolve in app UI" opens the real dialog populated from the response body (no live login needed).

313 (shared-definition conflict) is a save-call-site concern, not a resume

Unlike 310–312, a 313 resolution doesn't replay the same request — the user's choice changes which endpoint the save hits (iOS WorkoutModifyModel). So 313 is resolved where a schedule is saved, not in apiRequest, and actionForError leaves it null. The pieces are in place for when the schedule-save flow lands:

  • scheduleEndpoint(mode, resolution) (api/workouts.ts, unit-tested) — the endpoint table: base path first, then *_definition_update (edit shared) or *_definition_copy (private fork).
  • requestSharedConflictResolution({ canEditShared }) (app/error-handling/redirects/shared-conflict-store.ts) + SharedDefinitionConflictDialog + SharedConflictHost — the choice dialog ("Edit for everyone" is write-gated / "Save a private copy" / Cancel).

The call-site pattern a future saveSchedule uses:

async function saveScheduleWithConflictHandling(body, mode, canEditShared) {
try {
return await apiRequest(scheduleEndpoint(mode, null), {
method: "POST",
body,
});
} catch (e) {
if (!(e instanceof ApiError) || e.status !== 313) throw e; // not a conflict
const resolution = await requestSharedConflictResolution({ canEditShared });
if (!resolution) return null; // user cancelled
return apiRequest(scheduleEndpoint(mode, resolution), {
method: "POST",
body,
});
}
}

Status: the web app has no schedule-save-to-server flow yet (api/workouts.ts is read-only; WorkoutEditingFlow edits locally and only starts a session). So 313 has no live trigger — the dialog + endpoint table are ready, and /debug/errors → 313 → "Resolve conflict" demos the choice → endpoint mapping. (403 definitionAccessDenied is just a graceful error — no special handling.)

Cookbook

Every extension point is a table, registry, or seam — no new patterns required.

  • Handle a failure in a component: in a screen, const { present } = useErrorPresenter() then try { … } catch (e) { present(e) } (toast auto- chosen); present(e, { inline: true }) for a form-level InlineAlert; or let it throw to hit app/error.tsx. Outside a screen, presentError(e, t) or the core handleError(e).
  • Change how a status code behaves: edit one row in status-table.ts.
  • Add a required-action / redirect: add a RequiredAction variant (errors/required-action.ts), an action-table.ts row, an action-routes entry, and a resolution screen. Nothing else.
  • Record context for support: addBreadcrumb({ cat, msg, data? }) at the meaningful moment (@enode/core/diagnostics/breadcrumbs).
  • Send to support: openSupportReport(error?) from any surface, or reportToSupport(buildSupportReport(origin, { error })) directly.
  • Swap telemetry / transport: setErrorReporter(fn) / setSupportTransport(fn) once at startup (done in app/error-handling/ErrorEngineInit.tsx).

Surfaces (@enode/ui/feedback)

  • InlineAlert / ErrorAlert — graceful inline message (forms), variants error/warning/offline/info, optional onRetry/onReport/dismiss.
  • toast + Toaster — transient graceful/offline messages, callable from anywhere. For an application error (4xx/5xx) both Try again (primary) and Report (secondary) are offered; a transient offline failure drops Report. The present/presentError helpers wire this via toastActions(...).
  • BlockingErrorDialog — full-stop modal, always shows userMessage + “Contact support”. NOT for 401 (the redirect handles that).
  • FullStopState — the whole-screen full-stop that replaces the content area (vs the modal above): a haloed emblem, a faint brand mark, one primary action + a quiet support link. Presentational (caller passes t(...) strings); fills its parent's height, so place it in a height-bearing region. RequireData renders it for errorVariant="block".
  • StalenessHint — subtle “couldn’t refresh” pill over cached data.
  • SupportReportSheet — one-tap report form (opened via openSupportReport).

All use the --feedback-* design tokens — never raw red. Colours flip with the colour scheme automatically (no dark: variants).

Connectivity failure on a data screen: skeletons + a card, not a takeover

When a data screen's own load fails on the network, prefer keeping the loading skeletons and floating a card above them to a full-screen stop — a whole-screen red surface reads as "everything's broken", and zeroed summary cards misread as empty data when it's really the connection. The dashboard is the reference (app/dashboard/page.tsx): on error it keeps loading skeletons and renders a ConnectionCard (components/notices/). Every portal data screen follows the same pattern on a dataless failure (no cache to show): Dashboard, Planner, Devices, Users, Exercises, Workouts, Tags, History, Insights, Profiles, and Notifications each render the card above their still-skeletoned content with a working Try again. (A failed refresh over usable cache stays a StalenessHint — see the store-kit freshness doctrine.)

  • connectionModeFor(error, browserOnline) (@enode/core/connectivity) names the cause — offline (device has no network, via useBrowserOnline — the device-only signal, distinct from useIsOnline's reachability), unreachable (backend didn't answer), or maintenance (a 503, "back in a minute").
  • ConnectionCard renders it prominently (tone-tinted border + accent, a haloed emblem, a soft bloom) with Try again + Contact support. It's a fatal-ish state, so — unlike a stacked notice — it can't be minimized or dismissed; it clears when the load recovers.
  • Early surfacing: pages decide visibility through useShowConnectionCard( loading, failed) (exported next to the card) — it also shows the card while a load is still running once useIsOnline says we're down. That reachability signal is fed by every apiRequest outcome and the notifications SSE stream's heartbeat/drop detection (NotificationStreamHostrealtimeSubscribemarkOffline), so a dead backend surfaces within ~2s of the stream dropping instead of after the 15s request timeout. The card self-clears when the signal recovers and the pending load resolves normally.

Non-fatal, dismissible notices (launch announcements like migration/insights) instead live in the sidebar NoticeStack (components/notices/): at most one expanded (accordion), each dismissed on its own (persisted), up to three stacked then a carousel. Keep those out of a data view's body — the body is for its own data and status.

Reserve the full-screen FullStopState / RequireData errorVariant="block" for a view that genuinely can't render anything (missing essential reference data), not for a transient connectivity blip on a screen that can still show its frame.

Diagnostics & support

app/error-handling/ErrorEngineInit.tsx wires the engine at startup: the telemetry reporter, the UI dispatcher (so handleError from anywhere shows a toast), the support transport (the backend relay), and the global window.onerror / unhandledrejection handlers. Unhandled crashes auto-submit a report silently (consent-gated, deduped, auth-gated); handled errors and general feedback are an explicit one-tap report. Reports are vendor-neutral — see offline & sync and ADR 0002.

Requiring data a view can't work without

A failed request is a disposition (toast / inline / retry). A view that is missing essential reference data is a different problem: it can't render correctly, so it should show a loading or error state instead of a broken screen. Model that with store-kit + the <RequireData> gate, not ad-hoc ?. chains.

  • Each prefetch/lazy store embeds a createStoreStatus() controller and calls markLoading() / markReady(hasData) / markError(err) around its fetch (see exercises/store.ts for the reference wiring). Views read it via useStoreState(controller) / useFreshness(controller).

  • <RequireData> (@enode/ui/feedback/require-data) gates a view on one or more requirements and renders the children only when every one is satisfied — else a loading placeholder (still settling) or an error surface (a load failed, or returned empty/insufficient). The “expected count” is each requirement's satisfied predicate, e.g. categories.length >= 1:

    const metrics = useStoreState(metricsStatus);
    <RequireData
    requirements={[
    {
    state: metrics,
    satisfied: categories.length >= 1,
    label: t("metric categories"),
    },
    ]}
    onRetry={() => loadMetrics()}
    errorVariant="block" // whole screen needs it; "inline" for a degradable section
    showStaleHint // present-but-stale → a hint over the children, not an error
    >
    <CategoryList categories={categories} />
    </RequireData>;
  • The decision is the pure combineReadiness() (unit-tested). Rule of thumb, matching useFreshness: a view is only an error when it is dataless — if you can work with cached/partial data, make satisfied true and show a staleness hint instead of tearing the screen down.

  • The error-debug screen has a live demo (“App UI states → Required-data gate”).

Status note: store-kit is now wired into the eager reference stores — exercises, metrics, text-content, display, tracking, equipments, exercise-variations, tags, data-package-types — each exporting an xStatus controller (e.g. metricsStatus). A view gates on one with useStoreState(metricsStatus) + <RequireData>. The store-side change per store is createStoreStatus() + markLoading()/markReady(size > 0)/markError() around the fetch + reset() in clearX. No server change — the gate is the client comparing what it needs to what the existing response actually returned.

Debugging error states (dev screen)

app/debug/ is a hidden developer area, gated as a whole by debug/layout.tsx and split into segments behind a shared chrome (back button + tabs): Errors (/debug/errors) and Style guide (/debug/styleguide). Add a route + page to add a tab (debug/DebugChrome.tsx). The bug-icon entrance (next to the gear on the Today header) and the routes are gated by isDebugViewEnabled() (debug/flag.ts): NEXT_PUBLIC_ENABLE_DEBUG_VIEW="true" always shows it, "false" always hides it, unset → dev only (production 404s).

The Errors segment triggers every backend error UI state on demand — one button per /api/debug/errors/* endpoint, grouped into collapsible sections (Sanity, Custom 3xx, EnodeAbort, Arbitrary Vapor Abort, Uncaught). Each fire renders a card with the final status, wall-clock duration, pretty-printed body, the production classification, a structured drill-down for the custom 3xx bodies (310–313), and a runnable copy-as-cURL. A top-of-screen Accept-Language switcher (en/de/fr) re-fires the /enode/* endpoints to verify the localized userMessage, a masked Authorization strip tells prod/staging/ local apart, and a Fire all button sweeps every endpoint into a summary table.

  • See the real UI states — every fired error can be replayed through the actual presenter: a per-card "Show in app UI" / "As inline alert" button (or the "Auto-present on fire" toggle) calls useErrorPresenter().present(...), so the genuine toast / InlineAlert / BlockingErrorDialog appears exactly as in the app. An "App UI states" catalog panel additionally triggers every surface directly with representative data — toasts, the global handleError dispatcher, inline alerts (all variants), the staleness hint, the offline banner (markOffline/markOnline), the blocking dialog, the support report sheet, and the segment error boundary — so each state can be eyeballed without a backend.

  • Gating — the route lives behind isDebugViewEnabled() (app/debug/flag.ts): always on in dev; a production static export 404s the route unless NEXT_PUBLIC_ENABLE_DEBUG_VIEW=true is set at build time. The entrance (a bug icon next to the gear) is gated by the same flag. Reachable at /debug/errors.

  • Real pipeline, no teardown — every call goes through rawApiRequest (packages/core/src/api/client.ts), the non-throwing sibling of apiRequest: same headers, bearer auth, query serialization, timeout + caller-abort, connectivity signal and breadcrumbs, but it returns the status/body as data so 3xx/4xx/5xx can be inspected instead of thrown. The screen reproduces the production classification by running the real classifyError over a synthesized error, without dispatching the app-shell navigation events — so firing the 401 endpoint doesn't log you out mid-debug. The "treat 310–313 as success" toggle flips whether the custom 3xx render as classified errors or as plain inspectable responses.

Tests

Pure logic is unit-tested (errors/*.test.ts, api/with-retry.test.ts, store-kit/status.test.ts, the debug-screen helpers app/debug/errors/{curl,result}.test.ts, and api/raw-request.test.ts): the classifier across every disposition, the status and action tables, the body parser, retry/backoff, the reporter/dispatcher seams, the non-throwing rawApiRequest, and the debug cURL/normalization helpers. Run npm test.