4.8 KiB
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.statusMapfirst, then the package-wideHOST_DEFAULT_STATUS_MAP, finally falling back to HTTP 500 with the original code preserved. AbortError(or any error withcode === "ABORT_ERR") projects toHOST.CLIENT_ABORTED(499).SuaveplanErrorsubclasses (HostError, plugin-side errors carryingPLUGIN.<NAME>.<CODE>) pass theircodethrough unchanged.- Plain
Error/ strings / anything else surface asHOST.INTERNAL_INVARIANTby default; the dedicatedrunHandlerwrapper substitutesHOST.HANDLER_THREWso 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/runHandleris closed withrecordException+setStatus({code: 2, message: <projected-code>}). - Structured log line carries the cause chain (up to 5 levels) so a single log search resolves a request's failure path.
Usage: Basic
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:
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
class ErrorBoundary {
constructor(options: ErrorBoundaryOptions);
runStep<T>(
step: string,
plugin: string | null,
statusMap: StatusMap,
span: BoundarySpan | null,
fn: () => Promise<T> | T
): Promise<BoundaryOutcome<T>>;
runHandler<T>(
plugin: string,
statusMap: StatusMap,
span: BoundarySpan | null,
fn: () => Promise<T> | T
): Promise<BoundaryOutcome<T>>;
}
type BoundaryOutcome<T> =
| { 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.