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-ratelimitaccepts calls as fast as they arrive. The one throttle that is on by default isplugin-auth's 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; 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):
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 |
'ip' |
per client address — see Anonymous callers |
['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, remoteAddr? }.
Anonymous callers — by: 'ip'
Before sign-in every caller is the same anonymous subject, so 'subject' puts
all of them in one bucket, and one abuser can use up a sign-in or invite
endpoint for everyone. by: 'ip' keys on the caller's address instead:
rateLimitPlugin({
rules: [
{ match: 'auth.requestCode', kind: 'mutation', by: 'ip', limit: 5, window: '1m' },
],
})The address is the one the listener resolved: the socket address, or the
forwarded client when the peer is a declared proxy. Behind a load balancer,
declare it in security.trustedProxies.
Otherwise every caller has the balancer's address, and a per-IP bucket is just a
smaller global one. A client cannot choose the value. Calls that do not arrive
over the network (a schedule, a workflow step) share one unknown bucket.
Only the first matching rule applies to a call. To keep a total ceiling as well (say, on emails sent), enforce it in the handler, not as a second rule.
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 }.
On the public REST API (/v1) the same rejection is an HTTP answer: 429
with Retry-After and the RateLimit-Limit / RateLimit-Remaining /
RateLimit-Reset headers the http shield sets, and the error's tag and fields
as the body — { "error": "RateLimited", "tag": "teams.list", "limit": 30, … }.
The projection encodes through the same merged wire union rpc uses; a
descriptor's own errorStatus entry for RateLimited overrides the 429.
Before this the projection knew the descriptor's error: union only, so a
limited /v1 call was a 500 Internal Server Error — the one surface without
a typed channel to fall back on.
Per endpoint on the public API — publicApi.rateLimit
A descriptor's publicApi.rateLimit is that endpoint's own rule for calls
through the projection — ahead of rules and default, one bucket per caller
per endpoint, still under enabled:
publicApi: { rateLimit: { limit: 30, window: '1m' } }Only the /v1 call is covered; the same procedure over the rpc wire follows
the plugin's rules as before. The spec is inert without the plugin mounted.
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. Which means every limit is multiplied by the replica count:
100/minon five pods is 500/min, and a limiter is usually the thing standing between an endpoint and abuse. The plugin warns at boot when the environment says several replicas (POD_NAME,FLY_ALLOC_ID,K_REVISION, … — orREPLICA_COUNT, which is a declaration in both directions) and the store is still the process-local one.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 })The store connects at activation and, when nobody activated it, on its first
consume — from url or the env it would read at activation. That is what
makes it hold in the test harness: makeTestContext runs no lifecycle hooks,
and an unbound store answers every call allowed with failOpenReason: 'unbound', which a suite could not tell from a limit that held. A store with
neither url nor env stays fail-open, as at activation.
Bring your own backend entirely by passing any object that satisfies the
RateLimitStore interface to store:.
Inspect rate limits
Open Health & activity → Rate limits in the Cloud or local app inspector. With multiple instances, select the instance first. Its effective inspect mount comes from the app's plugin inventory; the dashboard does not choose a limiter implicitly. This read-only view is available with the app's inspect read token in both development and production.
The view shows ordered RPC rules, the fallback rule, key dimensions, algorithms,
burst capacity and the number of tenant overrides. Dynamic callbacks are marked
as dynamic and are never executed by inspection. Edit rules in app.config.ts.
The optional HTTP IP shield is shown separately: enabled gates RPC limiting,
while the HTTP shield is configured independently through http.
Decision counts are cumulative for the responding process since this plugin instance started, even with a shared Redis or Postgres counter store. Checked and allowed requests, rejections, exemptions/unmatched calls, unbound stores and store-error bypasses are distinct. Fail-open observations mean requests passed without a limit check; they do not assert that the store is still failing now. SQL binding reports the binding state, not a live health probe. Remote bucket counts that cannot be measured are unknown, not zero. IP addresses, subject IDs, tenant override keys and bucket keys are not exposed.
The plugin declares inspect:read and serves GET /state beneath its plugin
inspect mount with process-scoped observation metadata. Custom stores that
deliberately fail open should return failOpenReason: 'unbound' or
failOpenReason: 'store-error' on the allowed RateLimitDecision, so these calls
are not reported as successful checks.
Related features
- Mutations — the interceptor chain rate limiting hooks into
- Multi-tenancy — where
subject.tenantIdcomes from