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,108 @@
# middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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.