# Rate limiting

> Per-endpoint, per-subject and per-tenant request limits via the rpc interceptors. Sliding-window / fixed-window / token-bucket, memory / postgres / redis stores.



---

<!-- source: en/plugins/ratelimit.md -->
## Rate limiting

_Per-endpoint, per-subject and per-tenant request limits via the rpc interceptors. Sliding-window / fixed-window / token-bucket, memory / postgres / redis stores._

`@voltro/plugin-ratelimit` enforces request limits at the **rpc interceptor**
level — per endpoint, per subject, per tenant. Highly configurable: ordered
rules, three algorithms, composite keying, static + dynamic per-tenant
overrides, and an optional coarse pre-auth IP shield.

Why the rpc interceptors and not an HTTP middleware: rpc traffic rides one
long-lived WebSocket, so an HTTP-level limiter only ever sees the single
upgrade — never the individual calls. The Effect-native rpc interceptors are
the only surface that sees each call **and** the resolved subject (needed for
per-tenant limits).

> **This plugin is opt-in, and nothing rate-limits your RPC surface until you add it.**
> There is no built-in per-IP, per-API-key or per-tenant request cap — an app that
> does not install and configure `@voltro/plugin-ratelimit` accepts calls as fast as
> they arrive. The one throttle that *is* on by default is `plugin-auth`'s
> [brute-force lockout](/docs/authentication/passwords#brute-force-lockout) on sign-in,
> and it only covers credential attempts. Put a per-IP cap at your ingress as well —
> see [production hardening](/docs/deployment/production-hardening); that layer holds
> per-IP state across replicas, which an in-process limiter cannot.

Live — `demo.limited` is capped at 5/min per visitor; the 6th call within a
minute fails with the typed `RateLimited` error (carrying `retryAfterMs`):

```tsx
const call = useAction('app', 'demo.limited')   // 5/min → typed RateLimited
```

## Quick start

```ts
// app.config.ts
import { rateLimitPlugin } from '@voltro/plugin-ratelimit'

export default {
  type: 'api' as const,
  name: 'myApi',
  plugins: [
    rateLimitPlugin({
      // global fallback: 100 calls / minute / subject
      default: { limit: 100, window: '1m' },
      rules: [
        // tighter, per-endpoint, token-bucket with burst
        { match: 'todos.create', limit: 10, window: '1m', algorithm: 'token-bucket', burst: 15 },
        // every admin.* mutation, one shared bucket per tenant
        { match: /^admin\./, kind: 'mutation', limit: 1000, window: '1h', by: 'tenant', scope: 'rule' },
      ],
      // per-tenant limits from the subject's plan claim
      resolve: (ctx) =>
        ctx.subject.type !== 'anonymous' && ctx.subject.metadata?.plan === 'enterprise'
          ? { limit: 10_000, window: '1m' }
          : undefined,
      store: 'memory', // 'memory' (default) | 'postgres' | 'redis' (any RESP server)
    }),
  ],
}
```

## Rules — per endpoint

Rules are evaluated top-to-bottom; the **first** whose `match` + `kind` accept
the call wins. If none match, `default` applies; if there's no `default`, the
call is unlimited.

```ts
rules: [
  { match: 'todos.create', limit: 10, window: '1m' },          // exact rpc tag
  { match: ['posts.create', 'posts.update'], limit: 30, window: '1m' }, // tag list
  { match: /^admin\./, limit: 1000, window: '1h' },            // RegExp over tags
]
```

| Field | Meaning |
|---|---|
| `match` | `string` (exact tag) · `string[]` (any of) · `RegExp` (pattern). Omitted → every tag. |
| `kind` | `'mutation' \| 'query' \| 'action'` or an array. Omitted → all kinds. Limit writes but not reads. |
| `limit` | allowance per window. `number`, or `(ctx) => number` for dynamic per-tenant/plan logic. |
| `window` | `'1m'`, `'10s'`, `'500ms'`, `'2h'`, `'1d'`, or raw milliseconds. Static or `(ctx) => …`. |
| `algorithm` | `'sliding-window'` (default), `'fixed-window'`, `'token-bucket'`. |
| `burst` | token-bucket capacity; defaults to `limit`. Ignored by the window algorithms. |
| `by` | keying dimension(s) — see below. Default `'subject'`. |
| `scope` | `'tag'` (default: a bucket per endpoint) vs `'rule'` (one bucket across all tags the rule matches). |
| `tenants` | static per-tenant overrides — see below. |

`default` is the same shape minus `match` (it covers whatever no rule matched).

## Algorithms

- **`sliding-window`** (default) — weighted-counter approximation; smooth, no
  edge bursts, two integers of state per key.
- **`fixed-window`** — cheapest; can allow up to 2× at window edges.
- **`token-bucket`** — smooth refill with a configurable `burst` capacity.
  Good for "N steady-state, but allow a batch of `burst` now and then".

## Keying — who shares a bucket

`by` controls what the bucket is keyed on. Composite arrays join into one key.

| `by` | Bucket scope |
|---|---|
| `'subject'` *(default)* | per authenticated principal (already tenant-scoped) |
| `'tenant'` | one shared bucket for the whole tenant |
| `'apiKey'` | per api key (falls back to subject for non-apiKey subjects) |
| `'global'` | one bucket for the rule across everything |
| `['tenant','subject']` | composite — one bucket per (tenant, user) |
| `(ctx) => string` | custom key |

`scope` is orthogonal: `'tag'` (default) gives each matched endpoint its own
bucket; `'rule'` shares one bucket across every tag the rule matches (e.g.
"200 writes/min total across all mutations").

## Per-tenant limits

Two ways, and they compose — static first, then the dynamic hook wins:

**Static `tenants` override** on a rule — different limit (or exemption) per tenant id:

```ts
{
  match: 'ai.chat', limit: 50, window: '1m',
  tenants: {
    free: { limit: 10 },
    ent:  { limit: 5_000 },
    internal: { disabled: true },   // exempt this tenant entirely
  },
}
```

**Dynamic `resolve` hook** — fully programmatic, sync / Promise / Effect.
Runs after a rule + its static tenant override resolve; whatever it returns
wins. Return a limit override, `'exempt'`, or `undefined` to keep the rule's:

```ts
resolve: (ctx) =>
  ctx.subject.type !== 'anonymous' && ctx.subject.metadata?.plan === 'enterprise'
    ? { limit: 10_000, window: '1m' }
    : undefined,
```

The `ctx` is `{ tag, kind, subject, tenantId, traceId }`.

## Master gate — `enabled`

`false` disables all limiting; a function gates per-call (e.g. exempt a tenant
or a maintenance bypass). Returning `false` means "no limit for this call".

```ts
rateLimitPlugin({
  default: { limit: 100, window: '1m' },
  enabled: (ctx) => ctx.tenantId !== 'vip',   // VIP tenant is never limited
})
```

## Coarse pre-auth IP shield — `http`

Optional front-door guard keyed by remote IP, **before** any subject is
resolved. It fires on every HTTP request — inspect, webhooks, AND the rpc
WebSocket upgrade. Use as a cheap DoS shield in addition to the per-subject
rpc rules. Adds the `http:intercept` permission automatically.

```ts
rateLimitPlugin({
  default: { limit: 100, window: '1m' },
  http: { limit: 1000, window: '1m' },   // 1000 connections/min/IP at the door
})
```

When the shield trips it returns `429` with a `Retry-After` header plus the IETF draft `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers (`RateLimit-Reset` is seconds-until-reset), so the caller can read its quota position.

## Rejected calls — the `RateLimited` error

When a call exceeds its limit the interceptor fails with a typed error on the
rpc error channel. The plugin merges `RateLimited` into **every** procedure's
wire error union (server group + generated client group stay in lockstep), so
the client decodes it **typed** — not as an opaque defect:

```ts
class RateLimited extends Schema.TaggedError('RateLimited')({
  tag:          Schema.String,   // the rpc tag that was limited
  limit:        Schema.Number,   // the effective limit hit
  windowMs:     Schema.Number,   // the window it applies over
  retryAfterMs: Schema.Number,   // suggested wait before retry
  resetAtMs:    Schema.Number,   // epoch-ms when the bucket replenishes
})
```

Client narrows on `_tag` and shows a retry timer:

```tsx
const result = await create.mutate(input).catch((e) => e)
if (result?._tag === 'RateLimited') {
  toast.error(`Slow down — retry in ${Math.ceil(result.retryAfterMs / 1000)}s`)
}
```

For observability, pass `onLimited(info)` — a best-effort callback (swallowed
on throw) fired on every rejection with `{ tag, kind, key, tenantId, limit, windowMs, retryAfterMs }`.

## Storage backends

A rate limiter needs an **atomic** counter (an increment that can't lose
updates under concurrency), so it can't reuse a plain get/set cache. Three
stores ship, all fail OPEN on backend errors (degrade to no-limit, never to 500s):

- **Memory** *(default)* — single-process, zero-config. Perfect for dev and
  single-instance deploys; not shared across replicas.
- **Postgres** — multi-node correct. Runs on the framework's **already-open
  `SqlClient`** — the same connection pool the app itself uses — so it reads no
  `DB_*`/`PG_*` env of its own and never opens a second pool. The plugin binds
  that client for you when `store: 'postgres'` is selected. It mutates counter
  state under a `SELECT … FOR UPDATE` row lock plus a transaction-scoped
  advisory lock (cold-key safe). The pg driver (`@effect/sql`) is loaded lazily,
  only on this path — a memory- or redis-only app never bundles it.
- **Redis (RESP family)** — multi-node, fastest. Runs the whole decision in
  one atomic Lua script. Works against **any RESP-compatible server, not just
  Redis itself**:

  | Server | Notes |
  |---|---|
  | **Redis** | the reference RESP server |
  | **Valkey** | the Linux-Foundation fork of Redis 7.2 |
  | **KeyDB** | multithreaded, Redis-compatible |
  | **Dragonfly** | modern multi-core drop-in |
  | **Upstash** | serverless Redis over REST (HTTP driver) |

`'redis'` is the umbrella for the **whole RESP family** — the server brand
(Valkey, KeyDB, Dragonfly, …) is just the url; you never pick a per-server
type. The one real choice is the **driver**, mirroring `@voltro/cache`:

- `'resp'` *(default)* — `ioredis` over TCP. Covers Redis, Valkey, KeyDB,
  Dragonfly, and Upstash's TCP endpoint.
- `'http'` — `@upstash/redis` over REST, for serverless/edge where TCP isn't
  available.

Both are optional dependencies, loaded only when the redis store is used.

```ts
rateLimitPlugin({
  default: { limit: 100, window: '1m' },
  store: 'redis',   // 'memory' (default) | 'postgres' | 'redis' (any RESP server)
})
```

`store: 'redis'` reads the same env as the cache plugin — `CACHE_REDIS_URL`
(falling back to `REDIS_URL`), `CACHE_REDIS_DRIVER` (`resp` default | `http`),
and `CACHE_REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN` for the http driver. An app
already pointed at Valkey/Upstash for caching gets rate-limiting on the same
backend for free.

For a custom client/url/driver — or to share an existing connection — use the factory:

```ts
import { redisStore } from '@voltro/plugin-ratelimit/redis'

// Valkey / KeyDB / Dragonfly / Redis over TCP — just the url:
store: redisStore({ url: 'rediss://valkey.internal:6379' })

// Upstash over REST (serverless/edge):
store: redisStore({ driver: 'http', url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN! })

// or share an existing client (ioredis or @upstash/redis):
store: redisStore({ client: myClient })
```

Bring your own backend entirely by passing any object that satisfies the
`RateLimitStore` interface to `store:`.

## See also

- [Mutations](/docs/data/mutations) — the interceptor chain rate limiting hooks into
- [Multi-tenancy](/docs/multi-tenancy/overview) — where `subject.tenantId` comes from
