# middleware _(no submodules)_ ## Purpose The package's centerpiece. `createRateLimitMiddleware` builds a per-request gate around the token-bucket store; the gate emits 429 responses with `Retry-After`, increments the `api.rate_limit.exceeded_total` counter on every denied request, and projects itself onto the host's `RequestRateLimiter` shape so the synchronous `consume(key, weight)` call site in the host's request pipeline gets the same backing store + instruments without re-deriving the route policy. ## Features - Route-declared budget `{ rpm, burst?, key? }` with Zod validation at the ingestion boundary. `burst` defaults to `max(1, ceil(rpm/60))` when omitted so a 1-rpm route still tolerates a single-request burst. - Host-level defaults (`hostDefaults.perTenant`, `hostDefaults.perIp`) applied when a route declares no `rateLimit`. Route-level declarations always win. - 429 response carries `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`. Allowed responses carry `X-RateLimit-*` hints without `Retry-After`. - OTel counter `api.rate_limit.exceeded_total` increments once per denied request, labelled `{ plugin, route, key_kind }`. - `asRequestRateLimiter({ plugin, route, option?, keyKind? })` — bridges into the host's `consume(key, weight) → boolean` contract so the host pipeline keeps the same backing store / instruments. - When no budget applies (no route option, no host default), the gate is a no-op so plugins that never opt in pay zero overhead. ## Usage: Basic ```ts import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit"; const mw = createRateLimitMiddleware(); const decision = mw.check({ plugin: "billing", route: "POST /invoices", option: { rpm: 60, burst: 5 }, ctx: { tenantId: "t1", principal: { id: "alice" }, ip: "10.0.0.1", request: new Request("https://h/invoices", { method: "POST" }), }, }); if (!decision.allowed) { // Return new Response(null, { status: 429, headers: decision.headers }) } ``` ## Usage: Advanced Inject a host-level default for IP-keyed buckets and a custom selector on a specific route. Drive the bucket forward against a `FakeClock`: ```ts import { createRateLimitMiddleware } from "@suaveplan/api-rate-limit"; import { createMemoryRateLimitStore } from "@suaveplan/api-rate-limit/store"; let now = 0; const clock = () => now; const store = createMemoryRateLimitStore({ clock }); const mw = createRateLimitMiddleware({ store, clock, hostDefaults: { perIp: { rpm: 30, burst: 3 } }, }); const limiter = mw.asRequestRateLimiter({ plugin: "billing", route: "POST /invoices", option: { rpm: 60, burst: 5 }, keyKind: "principal", }); limiter.consume("principal:alice", 1); // → true, drains 1 token ``` ## API Reference - `interface RateLimitRouteOption { rpm: number; burst?: number; key?: RateLimitKeySelector }` - `const RateLimitRouteOptionSchema` — Zod schema, runs at the ingestion boundary. - `interface HostRateLimitDefaults { perTenant?: RateLimitBudget; perIp?: RateLimitBudget }` - `interface CreateRateLimitMiddlewareOptions { store?; instruments?; clock?; hostDefaults? }` - `interface RateLimitDecision { allowed; status; retryAfterSeconds; headers; key; keyKind }` - `interface RequestRateLimiter { consume(key, weight): boolean }` - `interface RateLimitMiddleware { check; asRequestRateLimiter; store; instruments }` - `function createRateLimitMiddleware(options?: CreateRateLimitMiddlewareOptions): RateLimitMiddleware` ## Implementation Notes The Zod schema runs once per route-option ingestion (cheap, not per request) — the `check` hot path consults the parsed shape directly. The decision returns a frozen header bag so downstream layers cannot mutate it unintentionally. The `key_kind` attribute is bounded to the four-value union from `ResolvedKey.kind` so OTel cardinality stays bounded even for a custom selector. The `asRequestRateLimiter` projection captures route policy at construction time because the host's synchronous `consume(key, weight)` shape has no slot for it; this keeps the host's per-request hot path single-load instead of re-resolving the route policy on every call.