← all parts

ratelimit.api v1.0.1

Fixed-window API rate limiting through a contract-stable interface, with a built-in per-instance in-memory store and a typed pluggable-store seam for Redis-compatible backends.

provides ratelimit.api@1

Attestations

adapterdefault
verified2026-06-11
expires2026-06-25
conformance17 tests
signaturedev (pre-v0)

Invariants

Testable claims, not adjectives — each maps to at least one named conformance test.

  • Importing the part performs no I/O and never throws; rules and configuration are validated at call time with typed errors
  • Within one fixed window, the first `limit` requests for a key are allowed and every request beyond `limit` is rejected
  • When the window elapses the counter resets: a key blocked in one window is allowed again in the next
  • Keys are isolated — exhausting one key's budget never consumes or blocks another key's budget
  • Every result reports accurate limit, non-negative remaining, and resetAt; the middleware answers 429 with Retry-After and IETF RateLimit-* headers, and rateLimitHeaders exposes the same set for allowed responses
  • An invalid rule (non-positive or non-integer limit/window) fails fast with a typed RateLimitError and zero store interactions
  • A store failure honors the configured policy — fail-open by default (request allowed, result flagged degraded) or fail-closed on opt-in (request rejected, result flagged degraded) — and the raw store error never escapes as an untyped exception

Interface

rateLimit(key: string, rule: RateLimitRule, opts?: RateLimitOpts): Promise<RateLimitResult> rateLimitMiddleware(config: RateLimitConfig): (request: Request) => Promise<Response | null> rateLimitHeaders(result: RateLimitResult): Record<string, string> tooManyRequests(result: RateLimitResult): Response class RateLimitError extends Error { code: RateLimitErrorCode } types: RateLimitRule, RateLimitResult, RateLimitOpts, RateLimitConfig, RateLimitStore, RateLimitErrorCode

Seams — what your app writes

Sufficient without reading src/; that is part of the quality bar.

# Seams — ratelimit.api What YOUR app provides. Reading `contract.json` + this file is enough to wire the part — you never need to read `src/`. Never edit `src/` (attested interior; edits void the attestation and fail CI). ## 1. No environment, no adapter — configured in code This part reads **no env vars** and ships **no registry adapters**. Rate-limit rules are per-route policy, so they live in your code, not in `.env`. Import and configure: ```jsonc // tsconfig.json → compilerOptions (recommended alias) "paths": { "@parts/*": ["./parts/*/src"] } ``` ```ts import { rateLimitMiddleware, rateLimit, rateLimitHeaders } from "@parts/ratelimit.api"; ``` Plain relative imports of `parts/ratelimit.api/src/index.js` work too. Never deep-import `src/internal/**` (lint-enforced). ## 2. The middleware seam (the common case) `rateLimitMiddleware(config)` returns `(request) => Promise<Response | null>`: a `429` Response when over the limit, `null` to pass through. Start from `examples/next-middleware.ts` (outside the boundary, freely copyable): ```ts const limiter = rateLimitMiddleware({ rule: { limit: 60, windowSeconds: 60 } }); export async function middleware(req: Request) { const limited = await limiter(req); return limited ?? NextResponse.next(); } export const config = { matcher: ["/api/:path*"] }; ``` A `429` carries `Retry-After` and the IETF `RateLimit-Limit / -Remaining / -Reset` headers. To advertise the budget on **allowed** responses too, call the `rateLimit` primitive in your handler and apply `rateLimitHeaders(result)` to your own response — middleware that passes through cannot attach them. ## 3. The store seam — bring Redis for cross-instance limiting The built-in store is in-memory and **per instance** (§6). For real limits across a serverless fleet, pass a `RateLimitStore`: ```ts interface RateLimitStore { // Atomically increment the counter at bucketKey, return the NEW value, and // expire the entry after ttlSeconds. Redis: INCR then EXPIRE. increment(bucketKey: string, ttlSeconds: number): Promise<number> | number; } ``` Reference Redis (ioredis) implementation: ```ts const store: RateLimitStore = { async increment(bucketKey, ttlSeconds) { const n = await redis.incr(bucketKey); if (n === 1) await redis.expire(bucketKey, ttlSeconds); return n; }, }; rateLimitMiddleware({ rule, store }); ``` **Atomicity matters:** the count is only correct if `increment` is atomic per `bucketKey`. `INCR` is; a get-then-set is not and will undercount under load. ## 4. Identifying the client — the trust boundary `identify(request)` derives the key. Default: the client IP — first `x-forwarded-for` hop, then `x-real-ip`. > **Security:** those headers are client-settable unless a trusted proxy > overwrites them. Behind Vercel/Cloudflare/your LB the default is fine; on > untrusted ingress an attacker spoofs `x-forwarded-for` to dodge the limit. > Then supply your own `identify` — an authenticated user id is ideal: > `identify: (req) => req.headers.get("x-user-id")`. If `identify` returns `null`/empty, the request lands in a single shared bucket — see the shared-bucket caveat in §6. ## 5. Fail-open vs fail-closed When the store throws (Redis down), the limiter does **not** throw — it returns a degraded result per `failOpen`: - `failOpen: true` (default) — request **allowed**, `result.degraded === true`. A store outage must not become an API outage. - `failOpen: false` — request **rejected**. Use only for abuse-critical endpoints where blocking beats letting traffic through unmetered. `RateLimitError` is thrown only for programming mistakes (`invalid_rule`, `invalid_config`), never for a store failure. ## 6. What v1 does and does not give you - **Per-instance counting.** The built-in store lives in one process's memory; N serverless instances enforce N× the limit in aggregate. Use a shared store (§3) for a true global limit. Durable, cross-instance counting without external infra arrives as an additive minor with the DB story. - **Shared-bucket DoS.** Keyless requests share one bucket, so one client can exhaust it for all keyless traffic. Always set a real `identify` (§4) in production. - **Fixed window, not sliding.** A burst straddling a window boundary can send up to `2 × limit` in a short span. Sliding-window is a future capability. ## 7. What you must NOT do - Edit or import anything under `src/internal/**`. - Trust the default IP key on untrusted ingress (§4). - Treat a `429` or a `degraded` result as a bug — they are the limiter working. - Use a non-atomic custom store (§3).

Install

$ partkit add ratelimit.api