API · Rate limiting

Per-endpoint / per-subject / per-tenant rpc rate limiting with @voltro/plugin-ratelimit — a default fallback + rules (exact tag or regex), sliding-window / token-bucket algorithms, composite keying, and a typed RateLimited error auto-merged into the wire error union. Memory store, zero infra.

Throttle rpc calls without touching a handler. @voltro/plugin-ratelimit runs on the rpc interceptors — the one surface that sees every call AND the resolved subject. A default fallback applies everywhere; rules override per endpoint with a choice of algorithm and bucket key. Over-limit calls fail a typed RateLimited error the plugin merges into every procedure's wire error union. Memory store is single-node; postgres/redis for a cluster. Template id: api-ratelimit.

Scaffold

voltro create-project acme --api=api-ratelimit

Limits as config

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

rateLimitPlugin({
  default: { limit: 100, window: '1m' },                 // global fallback, per subject
  rules: [
    // 3/min per tenant, token-bucket (starts full → burst of 3, then refills)
    { match: 'notes.create', limit: 3, window: '1m', algorithm: 'token-bucket', burst: 3, by: 'tenant' },
  ],
  store: 'memory',
})
  • match — an exact tag or a /regex/; kind narrows to mutation/query/action.
  • algorithmsliding-window (default), fixed-window, or token-bucket (with burst).
  • by — the bucket key: subject (default), tenant, apiKey, global, or a composite array.

Typed RateLimited

There's no rate-limit code in the handler — the interceptor enforces it. An over-limit call fails RateLimited ({ tag, limit, retryAfterMs, resetAtMs }), auto-merged into the procedure's wire error union, so the client decodes it typed and can show a "try again in N seconds".

Try it

# Fire notes.create four times for tenant 'acme' — the 4th trips the limit:
for i in 1 2 3 4; do
  curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
    -d "{\"tag\":\"notes.create\",\"input\":{\"tenantId\":\"acme\",\"title\":\"n$i\",\"body\":\"x\"}}"
done
# → calls 1-3: { ok:true } ; call 4: { ok:false, error:{ _tag:"RateLimited", … } }

Multi-node

store: 'memory' is single-process. Switch to 'postgres' (state under a row lock, reuses DB_*/PG_*) or 'redis' (fastest — one atomic Lua script; reads CACHE_REDIS_URL / REDIS_URL). All shared stores fail open on a backend error — a limiter outage degrades to "no limit", never to "deny everything".

Anti-patterns

  • Rate-limiting reads you didn't mean to. The default applies to queries too. Use kind: 'mutation' on a rule (or a tighter default) if you only want to throttle writes.
  • Reusing the cache store as the limiter store. A cache is get/set; a limiter needs an ATOMIC increment. They're separate backends — store here is independent of @voltro/cache.