Files
skills/docs/api-stack/api/api-host/src/request-pipeline/request-pipeline.md
2026-05-29 08:20:56 +01:00

6.1 KiB

RequestPipeline — §6 11-step per-request driver

(no submodules)

Purpose

RequestPipeline implements the design-doc §6 per-request data flow exactly: tenant resolve, CSRF check, session resolve, RBAC gate, rate limit, idempotency, request validation, plugin handler, response validation, audit emission, and telemetry close. The order matters — every later step assumes earlier steps' invariants, and reordering would silently regress the security boundary.

The driver was split into sibling modules during the foundation Wave-2 prep (pipeline split per CLAUDE.md file-size budget; behaviour unchanged). The class in this file is the public entry point and constructor; the orchestration body lives in request-pipeline-driver.ts, the per-step gates in request-pipeline-steps.ts, the Zod validators in request-pipeline-validation.ts, the audit / telemetry / failure-response builders in request-pipeline-emitters.ts, and pure helpers (request-id, URL parse, body reader, ctx builder) in request-pipeline-helpers.ts. Every existing test passes against the split without modification — that is the success metric.

Every step is wrapped by an ErrorBoundary so a throw at any point yields a fully-shaped ErrorEnvelope instead of an unhandled promise rejection. Tests + production both go through pipeline.handle(request) and inspect the returned PipelineResult.response / .envelope.

Features

  • Tenant resolver chain is honoured first — non-public routes that fail to resolve a tenant return HOST.TENANT_NOT_RESOLVED (400) before any other work happens.
  • Session resolver runs against the configured SessionResolverChain; resolver throws map to HOST.SESSION_REQUIRED via the boundary.
  • CSRF runs only on non-GET/HEAD requests with csrf: true, and is skipped on streaming: true routes per design.
  • RBAC compares principal.scopes to route.options.auth. Missing scope → HOST.RBAC_DENIED (403) + api.rbac.denials_total counter.
  • Rate limit bucket keyed by principal id (when present) else tenant id; bucket-empty → HOST.RATE_LIMIT_EXCEEDED (429).
  • Idempotency-Key consumed for idempotent: true routes; replay → HOST.IDEMPOTENCY_REPLAY (409).
  • Zod request validation: body (JSON only), params, query. Failure → HOST.REQUEST_VALIDATION_FAILED (400) carrying up to 10 Zod issues in details.
  • Plugin handler invoked with a RequestContext carrying capabilities, principal, tenant, span, request id, and the parsed body/params/query attached as discoverable fields.
  • Response validation strictness enforce (default) → 500 on mismatch; warn → success path + api.validation.failures_total{where=response.warn}.
  • Audit envelope emitted for auditable: true routes (admin-API sink shared by default).
  • Per-request OTel span tree: api.request root + plugin.handler child. Counter + histogram emitted on every response.

Usage: Basic

import { RequestPipeline } from "@suaveplan/api-host/request-pipeline";
import { createHostInstruments } from "@suaveplan/api-host/otel";

const pipeline = new RequestPipeline(
    {
        router,
        auth: sessionResolverChain,
        tenant: tenantResolverChain,
        capabilities,
        rateLimit,
        idempotency,
        csrf,
        audit,
        logger,
        span: spanFactory,
    },
    createHostInstruments({ hostId: "h1", hostVersion: "0.1.0" })
);

const result = await pipeline.handle(request);
return result.response;

Usage: Advanced

responseValidation: "warn" is the recommended dev-mode setting — the handler-supplied response propagates even on a schema mismatch but the counter spike makes the violation visible:

const pipeline = new RequestPipeline(
    { ...adapters, responseValidation: "warn" },
    instruments
);

Custom requestId + now injection point lets snapshot tests stay deterministic:

let clock = 0;
const pipeline = new RequestPipeline(
    {
        ...adapters,
        now: () => (clock += 1),
        requestId: () => "01J5DETERMINISTIC",
    },
    instruments
);

For streaming routes (options.streaming === true), request/response validation buffering is bypassed and the handler's Response body flows through unchanged — X-Request-Id is the only header the pipeline appends.

API Reference

class RequestPipeline {
    constructor(
        options: RequestPipelineOptions,
        instruments: HostInstruments
    );

    handle(request: Request): Promise<PipelineResult>;
}

interface PipelineResult {
    response: Response;
    status: number;
    route: string | null;
    plugin: string | null;
    outcome: "success" | "failure";
    envelope?: ErrorEnvelope;
    tenantId: TenantId | null;
}

const PIPELINE_STEPS: readonly PipelineStepName[];

Implementation Notes

The pipeline runs route-match first (technically step 2 in §6) so later step gates (auth, csrf, rateLimit, idempotent) can consult per-route policy. The §6 ordering of "intake → match" is preserved at the engine adapter; this driver assumes the intake has already happened (we receive a fully-formed Request). Step 1 "HTTP intake" is the router adapter's job and outside the pipeline's surface.

Tenant resolution returns a stub { id: "public", slug: "public" } context for public routes that failed to resolve — keeping downstream ctor invariants (ctx.tenant is never null) at the cost of one sentinel that's easy to recognise and filter from telemetry. Non-public routes still return 400 on null.

Per-step counters are derived directly from the boundary failure: the boundary emits api.errors_total for every captured throw, the pipeline adds the dedicated counters (api.auth.failures_total, api.rbac.denials_total, api.tenant.unresolved_total, api.rate_limit.exceeded_total, api.idempotency.replays_total, api.validation.failures_total) so a §12.3 spec slice is one counter lookup, not a regex over code labels.

Response validation runs after the handler returns. JSON-shape mismatches → 500 (enforce) / counter (warn). Non-JSON responses skip validation by design — a Response of an arbitrary media type is the plugin's contract with the engine.