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

{
  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 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.

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.tenantId comes from