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 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 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
'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/min on 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, … — or REPLICA_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 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 })

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.

  • Mutations — the interceptor chain rate limiting hooks into
  • Multi-tenancy — where subject.tenantId comes from