Skip to Content
API (apps/api)Failure Boundary (ADR-0041)

The observable failure boundary (ADR-0041)

docs/adrs/0041-effect-native-backend-execution-failure-and-observability.md (with ADR-0042 as its HTTP transport profile) defines how failures move through the backend: typed failures keep their causal evidence until a terminal runtime boundary makes an explicit outcome decision — and that boundary is the only thing allowed to log them.

Why it exists

Before ADR-0041, the codebase mixed styles: some ports returned Effect with typed errors, others Promise rejecting with unknown; call sites mapped rejections straight to empty 400/500s (losing the cause before any trace saw it) or logged-and-rethrew (duplicate terminal logs); HTTP statuses and retry policy leaked into reusable packages. The ADR’s goals: preserve typed failures and evidence to the boundary, keep package error algebras small and protocol-neutral, distinguish failure vs defect vs interruption, correlate every terminal failure to its trace/invocation/release, and never leak raw causes, payloads, SQL, secrets, or tenant data through logs or encoded errors.

The wire contract: packages/api-failure-contract

Every API failure crosses the wire as a bounded envelope:

// packages/api-failure-contract/src/envelope.ts interface ApiErrorEnvelope<Code extends string> { readonly error: { readonly code: Code // from a per-group failure catalog readonly message: string // length-capped safe copy readonly requestId: string readonly violations?: ReadonlyArray<{ path; code }> // count-capped } }

Codes come from composable catalogs (composeApiFailureCatalogs, per-group entries under src/groups/, plus platform-codes.ts); total response size is capped at API_ERROR_RESPONSE_MAX_LENGTH (8 KiB). The portal decodes envelopes with the same package (see portal data access) — one shared contract, no guessing at error shapes.

Runtime implementation in apps/api

The pieces live in apps/api/src/failure-protocol/:

  • withApiFailureProtocol(api) brands an HttpApi so makeAppLayer refuses un-protocoled apis.
  • makeObservedApiFailureGroupRuntimeLayer('kpi', { servedContract: 'envelope', observationProfile: 'terminal' }) — the per-group runtime layer every entry installs as middleware.
  • makeApiFailureTransportError — builds the typed transport failure (group, status, catalog code, requestId).
  • ApiFailureEvidenceStore — carries the rich evidence (the original cause, mechanism, etc.) alongside the request for terminal observation, without ever serializing it to the client.
  • summarizeApiFailureCause — reduces an Effect Cause to bounded counts (fail/die/interrupt reason counts). This is what server.ts logs on unhandled causes; the framework logger is disabled so nothing else can print a raw stack.
  • hardenApiFailurePlatformResponse — final response hardening applied in toLambdaHandler.

A concrete example — the KPI entry mapping an auth failure (from apps/api/src/entries/kpi.ts):

const failure = makeApiFailureTransportError({ group: 'kpi', status: 401, code: 'ApiUnauthorized', requestId: requestContext.requestId, sourceEvidence: evidence, // { kind: 'auth', mechanism: 'workos_jwt', source: cause, ... } }); const evidenceStore = yield* Effect.serviceOption(ApiFailureEvidenceStore); if (evidenceStore._tag === 'Some') evidenceStore.value.register(failure, evidence); return yield* Effect.fail(failure);

The client sees { error: { code: "ApiUnauthorized", message, requestId } }; the evidence (including the original cause) stays server-side for the terminal observer.

Pitfalls

  • A discarded cause is unrecoverable. If a handler maps an error to a typed 500 without preserving the cause at the catch: site, the framework tap can only record which route 500’d — mapped transport failures must carry their own evidence.
  • Log once, at the boundary. Logging an error inside a package and also rethrowing produces the duplicate terminal logs ADR-0041 exists to eliminate. Packages return typed failures; the runtime observes.
  • No HTTP in packages. Status codes, retry policy, and DLQ behavior are runtime decisions — reusable packages stay protocol-neutral (ADR-0041 non-goals).
  • Gates enforce this. check:api-failure-serving-gate and check:api-failure-no-new-debt run inside bun run verify (step 6 of adding an endpoint); a group that doesn’t serve the envelope, or new untyped failure debt, fails CI.

Related: request-level tracing and the http.request root span are covered in the architecture overview; ADR-0016 (audit vs telemetry) and ADR-0027 (authorization evidence) govern the durable audit events this protocol deliberately does not replace.

Last updated on