Initial Commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user