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).
Live — demo.limited is capped at 5/min per visitor; the 6th call within a
minute fails with the typed RateLimited error (carrying retryAfterMs):
const call = useAction('app', 'demo.limited') // 5/min → typed RateLimited
Quick start
// 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.
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 configurableburstcapacity. Good for "N steady-state, but allow a batch ofburstnow 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:
{
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:
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".
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.
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:
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:
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 noDB_*/PG_*env of its own and never opens a second pool. The plugin binds that client for you whenstore: 'postgres'is selected. It mutates counter state under aSELECT … FOR UPDATErow 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) —ioredisover TCP. Covers Redis, Valkey, KeyDB, Dragonfly, and Upstash's TCP endpoint.'http'—@upstash/redisover REST, for serverless/edge where TCP isn't available.
Both are optional dependencies, loaded only when the redis store is used.
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:
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 — the interceptor chain rate limiting hooks into
- Multi-tenancy — where
subject.tenantIdcomes from