# 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:

```
<feature>:<scope>:<discriminator>
```

| Segment           | Example       | Meaning                                      |
| ----------------- | ------------- | -------------------------------------------- |
| `<feature>`       | `signIn`      | Use-case or feature slug (camelCase)         |
| `<scope>`         | `ip`          | Budget name — matches `rateLimit[].name`     |
| `<discriminator>` | `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/<feature>/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/<feature>/src/integrations/api/procedures.ts
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { TooManyRequestsError } from "../../entities/errors/<feature>";

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/<your-adapter>/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<F>`     | `@repo/core-shared/conformance` | Phantom brand type; confirms rate-limit wrapping         |
