# Rate limiting cookbook Rate limiting is declared in the feature manifest, enforced at the use-case level via `IRateLimit`, and verified at boot time by `assertFeatureConformance`. This guide covers the manifest declaration, key-naming convention, multi-budget patterns, and backend wiring for each environment. --- ## How it fits together ``` feature.manifest.ts └── rateLimit: [{ name, window, budget }, ...] │ ▼ wireUseCase({ rateLimit: ctx.rateLimit ?? new NoopRateLimit() }) └── withRateLimit(rateLimit, factory(deps)) ← attaches __rateLimited brand │ ▼ assertFeatureConformance(container, manifest, symbols, ctx) └── checks __rateLimited brand when manifest.rateLimit.length > 0 ``` The conformance rule `no-undeclared-rate-limit` (ESLint, warn severity) verifies that every `rateLimit.consume("X", …)` call in a use-case file has a matching `{ name: "X" }` budget in the manifest, and that every declared budget is actually consumed. --- ## Manifest field declaration Add `rateLimit` to the use-case entry inside `feature.manifest.ts`: ```ts import { defineFeature } from "@repo/core-shared/conformance"; export const fooManifest = defineFeature({ name: "foo", requiredCores: [], useCases: { submitOrder: { mutates: true, audits: [], publishes: [], consumes: [], rateLimit: [ { name: "ip", window: "1m", budget: 10 }, { name: "user", window: "1h", budget: 100 }, ], }, }, realtimeChannels: [], jobs: [], } as const); ``` ### `RateLimitBudget` fields | Field | Type | Meaning | | -------- | -------- | ---------------------------------------------------------------------- | | `name` | `string` | Budget identifier; matches the first argument of `rateLimit.consume()` | | `window` | `string` | Rolling window duration. Accepted units: `ms`, `s`, `m`, `h`, `d` | | `budget` | `number` | Maximum requests (weight units) allowed within the window | Window string examples: `"500ms"`, `"30s"`, `"5m"`, `"2h"`, `"1d"`. Omitting `rateLimit` (or setting it to an empty array) means the use case is not rate-limited. `wireUseCase` still wraps it in `NoopRateLimit` so the slot type is always satisfied. --- ## Key-naming convention Keys partition the budget across distinct entities. Use the pattern: ``` :: ``` | Segment | Example | Meaning | | ----------------- | ------------- | -------------------------------------------- | | `` | `signIn` | Use-case or feature slug (camelCase) | | `` | `ip` | Budget name — matches `rateLimit[].name` | | `` | `203.0.113.5` | Per-entity value (IP address, user ID, etc.) | Canonical example from `auth/sign-in.use-case.ts`: ```ts const { allowed: ipAllowed } = await rateLimit.consume( "ip", `signIn:ip:${input.clientIp ?? ""}`, ); if (!ipAllowed) throw new TooManyRequestsError("Too many sign-in attempts"); const { allowed: accountAllowed } = await rateLimit.consume( "account", `signIn:account:${input.username}`, ); if (!accountAllowed) throw new TooManyRequestsError("Too many sign-in attempts"); ``` **Do not** use bare IPs or usernames as keys — include the feature and scope prefix so buckets from different use cases never collide in shared backends. --- ## Multi-budget patterns ### IP + account (credential-stuffing defence) Two independent budgets: one throttles by source IP, the other by target account. A single attacker cycling IPs can still be blocked by the account budget; many attackers hitting one account are caught by the per-IP budget. ```ts // Manifest rateLimit: [ { name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }, ], ``` ```ts // Use case body — check IP first (cheaper lookup) const { allowed: ipOk } = await rateLimit.consume( "ip", `signIn:ip:${input.clientIp ?? ""}`, ); if (!ipOk) throw new TooManyRequestsError("Too many sign-in attempts"); const { allowed: accountOk } = await rateLimit.consume( "account", `signIn:account:${input.username}`, ); if (!accountOk) throw new TooManyRequestsError("Too many sign-in attempts"); ``` ### Per-user action quota One budget limits how many times a single authenticated user can trigger an action per day: ```ts // Manifest rateLimit: [ { name: "user", window: "1d", budget: 50 }, ], ``` ```ts // Use case body const { allowed } = await rateLimit.consume( "user", `exportReport:user:${input.userId}`, ); if (!allowed) throw new TooManyRequestsError("Daily export limit reached"); ``` ### Weighted consume Pass a `weight` argument to consume multiple budget units in one call (e.g., bulk operations): ```ts // Costs 5 budget units instead of 1 const { allowed } = await rateLimit.consume( "user", `sendEmails:user:${input.userId}`, input.recipients.length, ); ``` The weight defaults to `1` when omitted. --- ## Throwing on rate-limit exceeded Throw `TooManyRequestsError` from `@repo//entities/errors`. The tRPC error middleware maps this to HTTP 429 via the feature's `xProcedure`: ```ts import { TooManyRequestsError } from "../../entities/errors/auth"; if (!allowed) throw new TooManyRequestsError("Too many sign-in attempts"); ``` Declare `TooManyRequestsError` in the feature's error file and register it in `integrations/api/procedures.ts`: ```ts // packages//src/integrations/api/procedures.ts import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import { TooManyRequestsError } from "../../entities/errors/"; export const featureProcedure = t.procedure.use( defineErrorMiddleware([ [TooManyRequestsError, "TOO_MANY_REQUESTS"], // …other error → tRPC code mappings ]), ); ``` --- ## Wiring a rate-limit backend ### Dev / test — `InMemoryRateLimit` `InMemoryRateLimit` is a single-process, Map-backed implementation suitable for local development and unit tests. Buckets live only in memory — they reset on process restart. ```ts import { InMemoryRateLimit } from "@repo/core-shared/rate-limit"; const rateLimit = new InMemoryRateLimit([ { name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }, ]); ``` Pass the same budget declarations as the manifest so dev behaviour matches production. ### Default (no backend) — `NoopRateLimit` `NoopRateLimit` always allows every request (`allowed: true`, `remaining: Infinity`). It is the default when `ctx.rateLimit` is absent, so features boot cleanly without a rate-limit backend wired: ```ts import { NoopRateLimit } from "@repo/core-shared/rate-limit"; const rateLimit = ctx.rateLimit ?? new NoopRateLimit(); ``` Use `NoopRateLimit` in unit tests that exercise the use-case logic but do not need to test throttling behaviour. Use `RecordingRateLimit` from `@repo/core-testing/rate-limit` in tests that must assert on `consume` / `reset` call counts. ### Production — external backend via `IRateLimit` Wire a production backend by implementing `IRateLimit` and passing the instance through `ctx.rateLimit` in the app's `bindAll` aggregator: ```ts // apps/web-next/src/server/bind-production.ts (excerpt) import { RedisRateLimit } from "@repo//rate-limit"; // your implementation const rateLimit = new RedisRateLimit(redisClient, { /* budget table loaded from manifest or config */ }); const ctx: BindProductionContext = { config: resolvedConfig, tracer, logger, queue, rateLimit, // passed to all feature binders }; bindProductionAuth(ctx); // …other features ``` Every feature binder receives the same `IRateLimit` instance via `ctx.rateLimit`. Feature binders that declare `rateLimit` budgets in their manifest pass the instance to the use-case factory: ```ts // packages/auth/src/di/bind-production.ts (excerpt) const wrappedSignIn = wireUseCase({ container: authContainer, symbol: AUTH_SYMBOLS.ISignInUseCase, factory: signInUseCase, deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()], feature: "auth", layer: "use-case", name: "signIn", tracer, logger, rateLimit: ctx.rateLimit ?? new NoopRateLimit(), // for brand attachment }); ``` `wireUseCase` wraps the factory output with `withRateLimit(rateLimit, fn)` and attaches the `__rateLimited` brand, which `assertFeatureConformance` checks at boot. ### Environment strategy summary | Environment | Recommended backend | How to wire | | ---------------- | ----------------------------------------------------- | ----------------------------------------------------- | | Unit tests | `NoopRateLimit` | Inject directly: `signInUseCase(repo, auth, noop)(…)` | | Rate-limit tests | `RecordingRateLimit` | Inject directly; assert `.calls` | | `pnpm dev` | `NoopRateLimit` | Default in `bindAllDevSeed` via `ctx.rateLimit` | | Staging / prod | `InMemoryRateLimit` or external Redis/Upstash adapter | Set `ctx.rateLimit` in `bindAllProduction` | --- ## Conformance gate The ESLint rule `conformance/no-undeclared-rate-limit` (warn) fires when: - A use-case file calls `rateLimit.consume("X", …)` but `"X"` is not in `feature.manifest.ts` → **undeclared budget** - A manifest entry declares `{ name: "X" }` but the use-case body never calls `rateLimit.consume("X", …)` → **unused declaration** Fix by keeping the `rateLimit` array in the manifest in sync with the `rateLimit.consume()` calls in the factory body. The boot-time assertion (`assertFeatureConformance`) also requires the `__rateLimited` brand when `rateLimit.length > 0` — the dev server refuses to start if the brand is missing. --- ## API surface quick-reference | Export | Package path | Purpose | | -------------------- | ------------------------------- | -------------------------------------------------------- | | `IRateLimit` | `@repo/core-shared/rate-limit` | Protocol interface for all backends | | `RateLimitBudget` | `@repo/core-shared/rate-limit` | Manifest budget descriptor `{ name, window, budget }` | | `RateLimitDecision` | `@repo/core-shared/rate-limit` | Result of `consume()`: `{ allowed, remaining, resetAt }` | | `InMemoryRateLimit` | `@repo/core-shared/rate-limit` | Single-process Map-backed implementation | | `NoopRateLimit` | `@repo/core-shared/rate-limit` | Always-allow stub (dev default) | | `withRateLimit` | `@repo/core-shared/rate-limit` | DI wrapper; attaches `__rateLimited` brand | | `RecordingRateLimit` | `@repo/core-testing/rate-limit` | Test helper; records `consume` / `reset` calls | | `RateLimited` | `@repo/core-shared/conformance` | Phantom brand type; confirms rate-limit wrapping |