3.1 KiB
3.1 KiB
@suaveplan/api-rate-limit — per-tenant / per-principal / per-IP token-bucket middleware
Package: @suaveplan/api-rate-limit
Status: PROPOSED
Type: new-package
Stage: 8 — see umbrella openspec/changes/api-plugin-foundation/
Why
Per design doc §6 step 4a, the per-request pipeline includes an optional rate-limit gate between tenant resolution and RBAC. Plugin routes opt in via rateLimit: { rpm, burst, key }. The host needs a deterministic, in-memory-first implementation that can be lifted onto a shared store (Redis) for multi-host fleets later without changing the contract.
What changes
- ADDED:
createRateLimitMiddleware({ store, clock })— produces aMiddlewarethat consumes the route'srateLimitoption (rpm, burst, key selector). Implements a token-bucket per key. 429 on deny withRetry-After. Emitsapi.rate_limit.exceeded_total{plugin, route, key_kind}. - ADDED:
RateLimitKeySelector— discriminated union:"tenant","principal","ip",{ kind: "custom", select: (ctx) => string }. The selector is per route; default is"principal"when authenticated, else"ip". - ADDED:
RateLimitStoreLike— abstract storage interface (take(key, weight) → { allowed, retryAfterMs, remaining }). Default in-memory implementation with TTL-evict ring buffer. Pluggable; anapi-rate-limit-redisadapter can land in a later change. - ADDED: Default host budgets configurable in
host.json— global per-tenant default, global per-IP default. Route-declared budgets override. - ADDED: Error codes (
RATELIMIT_*):EXCEEDED,STORE_UNAVAILABLE.
Out of scope
- Distributed/Redis backend — abstract interface present, adapter ships separately.
- Sliding-window or leaky-bucket alternatives — token bucket only in v1.
- Per-plugin global rate limiting (across all its routes) — out of scope, plugins can compose multiple route declarations.
Source mapping
Genesis-original. Source of design intent: docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md.
| Requirement | Design doc section |
|---|---|
| Per-request rate-limit gate (optional) | §6 step 4a |
| Per-tenant / per-principal / per-IP keys | §6 step 4a |
api.rate_limit.exceeded_total counter |
§12.3, §12.4 |
429 + Retry-After response |
§6 |
Verification approach
src/middleware/middleware.test.ts— token-bucket allow / deny matrices withFakeClock, including burst behaviour and refill correctness.src/middleware/middleware.spec-conformance.test.ts— pins observability:api.rate_limit.exceeded_totalincrements only on the denial transition (not every subsequent denied call within the same window);Retry-Afterheader matches the bucket's refill estimate.src/store/memory-store.test.ts— TTL eviction, key isolation, concurrent take correctness.src/selector/selector.test.ts— every key kind resolves correctly; missing principal falls back to IP.tests/dep-graph.test.ts— runtime deps (api-types,error,logger,typesClock).
Migration impact
None — additive only. api-host wires this middleware when any route declares rateLimit or when host-level defaults are set.