# `ErrorBoundary` — pipeline-step throw containment _(no submodules)_ ## Purpose The `ErrorBoundary` wraps every step of the per-request pipeline (design doc §6 steps 1–11) and projects every captured throw onto a stable, observable shape. It is the single chokepoint where a `SuaveplanError`, an OTel `AbortError`, or an unknown value escaping a step gets turned into a `BoundaryFailure` carrying a deterministic HTTP status, error code, message, and OTel span termination. Per the per-request pipeline, every step is wrapped — the boundary's per-failure side effects (counter emission, span close, structured log line) are uniform across pre-handler, handler, and post-handler steps. The boundary never throws — callers branch on the `BoundaryOutcome.ok` discriminator and never have to catch. ## Features - Status projection consults the per-plugin `manifest.errors.statusMap` first, then the package-wide `HOST_DEFAULT_STATUS_MAP`, finally falling back to HTTP 500 with the original code preserved. - `AbortError` (or any error with `code === "ABORT_ERR"`) projects to `HOST.CLIENT_ABORTED` (499). - `SuaveplanError` subclasses (`HostError`, plugin-side errors carrying `PLUGIN..`) pass their `code` through unchanged. - Plain `Error` / strings / anything else surface as `HOST.INTERNAL_INVARIANT` by default; the dedicated `runHandler` wrapper substitutes `HOST.HANDLER_THREW` so OTel slices handler bugs from host invariants. - Every failure emits `api.errors_total{plugin, step, code, status}` on the host instruments. - The OTel span passed to `runStep`/`runHandler` is closed with `recordException` + `setStatus({code: 2, message: })`. - Structured log line carries the cause chain (up to 5 levels) so a single log search resolves a request's failure path. ## Usage: Basic ```ts import { ErrorBoundary } from "@suaveplan/api-host/error-boundary"; import { ConsoleLogger } from "@suaveplan/logger"; import { createHostInstruments } from "@suaveplan/api-host/otel"; const boundary = new ErrorBoundary({ instruments: createHostInstruments({ hostId: "h1", hostVersion: "0.1.0" }), logger: new ConsoleLogger({ name: "host" }), }); const outcome = await boundary.runStep("auth.resolve", null, {}, null, async () => { return await sessionResolverChain.resolve(request); }); if (!outcome.ok) { return new Response(JSON.stringify({ error: outcome.failure }), { status: outcome.failure.status, }); } const principal = outcome.value; ``` ## Usage: Advanced Plugin-supplied `statusMap` overrides the host-default per code; per-plugin manifest fields land here directly: ```ts const statusMap = manifest.errors?.statusMap ?? {}; const handlerOutcome = await boundary.runHandler( manifest.name, statusMap, span, async () => await route.handler(ctx) ); if (!handlerOutcome.ok) { // `HOST.HANDLER_THREW` is the default code on unknown throws inside a // handler — manifest may map it to a friendlier 502 for retryable // upstream failures, for instance. return assembleEnvelope(handlerOutcome.failure); } ``` For streaming routes, the boundary still wraps the handler — once the handler returns a `Response` carrying a `ReadableStream`, downstream stream errors flow through the engine's native abort signalling and appear here as `HOST.CLIENT_ABORTED`. ## API Reference ```ts class ErrorBoundary { constructor(options: ErrorBoundaryOptions); runStep( step: string, plugin: string | null, statusMap: StatusMap, span: BoundarySpan | null, fn: () => Promise | T ): Promise>; runHandler( plugin: string, statusMap: StatusMap, span: BoundarySpan | null, fn: () => Promise | T ): Promise>; } type BoundaryOutcome = | { ok: true; value: T } | { ok: false; failure: BoundaryFailure }; interface BoundaryFailure { code: string; status: number; message: string; step: string; cause?: { code: string; message: string }; } ``` ## Implementation Notes The boundary intentionally does not own envelope assembly — that is the request pipeline's job. The boundary's failure shape carries everything the pipeline needs (`code`, `status`, `message`, `step`) and the pipeline adds the `requestId` / `traceId` / per-route `details` on top before serialising. Keeping that responsibility on the pipeline side lets unit tests around the boundary remain envelope-agnostic and makes future surface additions (e.g. `Retry-After` on `RATE_LIMIT_EXCEEDED`) a single-location edit. `recordException` + `setStatus({code: 2, ...})` is the OTel convention for ERROR status. The boundary mutates the span but never `.end()`s it — span termination remains the pipeline's prerogative so per-step child spans can close before the parent.