4.2 KiB
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.burstdefaults tomax(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 norateLimit. Route-level declarations always win. - 429 response carries
Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining: 0. Allowed responses carryX-RateLimit-*hints withoutRetry-After. - OTel counter
api.rate_limit.exceeded_totalincrements once per denied request, labelled{ plugin, route, key_kind }. asRequestRateLimiter({ plugin, route, option?, keyKind? })— bridges into the host'sconsume(key, weight) → booleancontract 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
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:
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.