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