Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
# `ErrorBoundary` — pipeline-step throw containment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```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<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.