Files
suaveplan/packages/api-stack/api/api-host/src/error-boundary/error-boundary.md
2026-05-27 23:29:53 +01:00

4.8 KiB
Raw Blame History

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.<NAME>.<CODE>) 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: <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.