Error Handling
AGENTS.md says to prefer optimistic code over defensive code and to handle errors architecturally at higher levels. Almost all of the codebase follows that literally: functions throw, nothing catches, and the failure surfaces loudly. Three boundaries are exceptions, and each has its own convention because each has a reason it cannot just throw:
| Boundary | Convention | Why it can’t throw |
|---|---|---|
| App boot | HandleResult<T> with a HandleErrorCode |
The recovery UI is chosen by the code, and there is no error boundary above the pipeline |
| Chat turn | A JSON envelope carrying a ChatErrorKind |
The error is serialized to a string on its way from the stream to the UI |
| Backend HTTP | ErrorResponse from safeErrorHandler |
An internal message must never reach the client |
Everywhere else, throw.
App boot: HandleResult<T>
src/types/handle-errors.ts defines a success/error
discriminated union, an error record carrying a closed 12-value code, and nothing else:
type HandleResult<T> = { success: true; data: T } | { success: false; error: HandleError }Exactly two functions return it: executeInitializationSteps
(src/hooks/use-app-initialization.ts:173) and
initPosthog (src/lib/posthog.tsx:81). useAppInitialization
(src/hooks/use-app-initialization.ts:428) unwraps the first into initData / initError, and
src/app.tsx:343 routes on initError.code.
The union earns its place here because the code — not the message — selects the recovery path.
STORAGE_UNAVAILABLE renders StorageUnavailableScreen; everything else renders AppErrorScreen,
which offers Clear Local Database only for the two codes where wiping local data is the actual
remedy (src/components/app-error-screen.tsx:48). A thrown Error carries none of that. The hook
does still catch: an unguarded throw from any step becomes UNKNOWN_ERROR rather than a permanent
loading spinner (src/hooks/use-app-initialization.ts:450).
Build the record with createHandleError(code, message, originalError)
(src/lib/error-utils.ts:279) rather than a literal — it lifts
originalError.stack onto stackTrace, which is what AppErrorScreen’s Contact Support
mailto and the PostHog exception both report.
The codes
| Code | Produced by | Effect |
|---|---|---|
STORAGE_UNAVAILABLE |
use-app-initialization.ts:203 |
Fatal — dedicated storage screen |
APP_DIR_CREATION_FAILED |
use-app-initialization.ts:214 |
Fatal — error screen |
DATABASE_INIT_FAILED |
use-app-initialization.ts:231, :253 |
Fatal — error screen with Clear Database |
RECONCILE_DEFAULTS_FAILED |
use-app-initialization.ts:343 |
Fatal — error screen |
HTTP_CLIENT_INIT_FAILED |
use-app-initialization.ts:381 |
Fatal — error screen |
UNKNOWN_ERROR |
use-app-initialization.ts:450 |
Fatal — error screen |
TRAY_INIT_FAILED |
use-app-initialization.ts:157 |
Tracked; boot continues with no tray |
POSTHOG_FETCH_FAILED |
posthog.tsx:152 |
Returned, then dropped; null client |
SYNC_ENABLE_FAILED |
src/contexts/sign-in-modal-context.tsx:85 |
Tracked and swallowed; post-boot |
CANARY_EXTRACTION_FAILED |
src/services/encryption.ts:228 |
Tracked, then rethrown; post-boot |
MIGRATION_FAILED |
no producer | Consumed — see below |
DATABASE_PATH_FAILED |
no producer | Unused outside stories and tests |
A code does not tell you whether a failure is fatal; the call site does. Nothing in the bottom
half of the table reaches initError, but they do not all end the same way: TRAY_INIT_FAILED and
SYNC_ENABLE_FAILED are handed to trackError and swallowed, CANARY_EXTRACTION_FAILED is tracked
and then rethrown to its caller, and POSTHOG_FETCH_FAILED is the one code that travels in a
HandleResult without ever being tracked — initializePostHog
(src/hooks/use-app-initialization.ts:135) unwraps it, discards the error and substitutes a null
client. The last two are not boot steps at all. Which boot steps are fatal and which are swallowed is
set by the pipeline, and is tabulated in
App Initialization.
MIGRATION_FAILED has no producer but is load-bearing on the consumer side: it is one of the two
codes that show the Clear Local Database button, so removing it changes recovery behaviour for any
future migration failure. DATABASE_PATH_FAILED is genuinely dead.
trackError (src/lib/posthog.tsx:293) captures the record as a PostHog exception, keyed on the
code as $exception_type. It drops POSTHOG_FETCH_FAILED — reporting an analytics failure through
analytics is circular. Boot call sites pass an initialization_step context property so a code that
has two producers (DATABASE_INIT_FAILED) stays separable on the dashboard.
Chat turns: ChatErrorKind
A failed turn’s error has to cross a string boundary. The AI SDK’s onError callback returns a
string, and the SDK flattens an APICallError to a bare "Bad Request" — losing the status code the
retry and attachment-remediation layers need. So serializeStreamError
(src/ai/fetch.ts:850) mints a JSON envelope instead, and the client parses
it back out:
{ "error": "<responseBody or message>", "status": 400, "isRetryable": false, "kind": "provider" }ChatErrorKind (src/lib/error-utils.ts:9) is the six-value closed
set of user-facing classes: attestation, timeout, rate-limit, provider, network,
connection-lost.
One classifier, three wire shapes
classifyErrorKind (src/lib/error-utils.ts:79) classifies from three normalized fields — error
name, HTTP status, and message, where an APICallError’s responseBody stands in for the message —
and has to cope with three different shapes because errors reach it from three transports:
- A structured error object, where the status is on
status,statusCodeorresponse.status. - Pi’s flattened text, where pi-ai’s
formatProviderErrorhas already collapsed the response into a string.getPiErrorStatusCode(:47) recovers the status from"<status>: <body>","<prefix> (<status>): <message>", or"<status> <JSON body>". - JSON inside
Error.message— the envelope above.getChatErrorKind(:115) parses it, and if it carries a validkindit trusts it rather than re-deriving; otherwise it re-classifies from the embedded status and message. Older payloads predate thekindfield, which is why the fallback exists.
That trust is not incidental: connection-lost is the one kind classifyErrorKind never produces.
It is minted pre-serialized by the ACP adapter when a transport dies
(src/acp/acp-adapter.ts:407), because no HTTP status distinguishes
“the agent’s socket dropped mid-turn” from any other failure — and the distinction matters, since the
agent may already have performed side effects.
400 and 422 are deliberately folded into provider. ChatErrorKind has no content-rejection
bucket, so a rejected file part reports as a provider problem (src/lib/error-utils.ts:97). The
narrow signal that drives attachment remediation is a separate predicate — see
Attachments.
The predicates around it
The kind answers “what do we tell the user”. These answer “what do we do next”, and the retry ladder
in src/chats/chat-instance.ts consults them one at a time rather than switching on the kind.
connection-lost is the single exception — it is checked as a kind (src/chats/chat-instance.ts:915)
because no predicate covers it:
| Predicate | Question |
|---|---|
isRateLimitError |
A 429, on any of the three wire shapes |
getInferenceQuotaWindow |
A managed-inference quota rejection, and which window |
isContextOverflowError |
Request too large for the model’s context window |
isContentRejectionError |
The endpoint rejected the form of a file part (400/422) |
getErrorRetryable |
The provider’s own retry verdict, when it survived the wire |
getErrorRetryable exists because “is it a 4xx” is the wrong question — it buckets a transient 408
with a deterministic 400. The full retry ladder, including the order these are checked in and why
each one refuses to retry, is in
Chat Runtime.
What must stay in step
- Adding a
ChatErrorKindneeds copy.causeSpecificErrorMessages(src/components/chat/error-message.tsx:21) is aPartial<Record<…>>, so a missing entry compiles and silently renders the generic “Something went wrong” instead. It also needs a retry verdict inchat-instance.ts— the default is to retry, which is right only for transient failures. - Changing
classifyErrorKindchanges telemetry. The retryreasononchat_auto_retry/chat_retries_exhaustedand thekindonchat_turn_errorare derived from it, and TELEMETRY.md spells the mapping out status by status. Update it in the same PR, or the dashboards document a classifier that no longer exists.
Backend: never leak internals
safeErrorHandler (backend/src/middleware/error-handling.ts:69)
is the Elysia onError handler the route modules install. It returns a fixed shape —
{ success: false, data: null, error } — where error is getSafeErrorMessage(status): the
standard HTTP reason phrase from Elysia’s InvertedStatusMap, never the thrown message. The
internal detail goes to the log instead, with the status, route, stack and cause chain.
Two deliberate pass-throughs: VALIDATION and NOT_FOUND return early and let Elysia’s defaults
handle them, because that output is already user-facing and safe.
Logging goes through getSafeLogMessage (:40), which special-cases DrizzleQueryError:
Drizzle interpolates parameter values — emails, tokens — into .message, so the handler logs the
structured .query (parameterized SQL) instead. Reaching for error.message in a new backend log
line reintroduces that leak.
The root createErrorHandlingMiddleware does not cover routes defined on plugins, which is why
nearly every route module calls .onError(safeErrorHandler) itself. See
Backend API Surface for the full plugin checklist.
There is no React error boundary
No component in src/ implements componentDidCatch, and no error-boundary package is a
dependency — the only boundary in the repo is a fixture local to
src/components/auth-gate/use-auth-gate.test.ts. Outside the boot pipeline there is therefore
nothing between a render-time throw and the React root, which unmounts the tree and leaves a blank
window. Async work in an event handler or effect is fine — that rejects, it does not unmount — but
code evaluated during render should not assume something will catch it.
Where the code lives
| File | Role |
|---|---|
src/types/handle-errors.ts |
HandleErrorCode, HandleError, HandleResult |
src/lib/error-utils.ts |
createHandleError, ChatErrorKind, every predicate |
src/lib/error-utils.test.ts |
The wire-shape corpus — add a case for any new shape |
src/hooks/use-app-initialization.ts |
The boot pipeline and its per-step error codes |
src/components/app-error-screen.tsx |
Fatal-boot UI, Clear Database, support mailto |
src/components/storage-unavailable-screen.tsx |
The STORAGE_UNAVAILABLE screen |
src/lib/posthog.tsx |
trackError and the circular-tracking guard |
src/ai/fetch.ts |
serializeStreamError — where the envelope is minted |
src/components/chat/error-message.tsx |
Per-kind chat copy and the Retry affordance |
backend/src/middleware/error-handling.ts |
safeErrorHandler, getSafeErrorMessage, log redaction |