rate-limit
Enforces per-client request rate limits using a sliding-window counter.
Injects X-RateLimit-* response headers on every request. On limited
requests, also returns 429 Too Many Requests with a Retry-After value.
The store is pluggable — any object implementing
hit(key, windowMs) → {count, resetMs, resetAt} can replace the built-in
MemoryStore for Redis-backed or distributed rate limiting. resetAt is the
absolute window-reset time in milliseconds in the store’s own clock domain
(so injectable clocks on MemoryStore produce correct X-RateLimit-Reset
headers).
Pipeline stage: Negotiation
Import
Section titled “Import”import {rateLimit} from '@centralping/ergo';Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
max |
number |
100 |
Maximum requests per window |
windowMs |
number |
60_000 (1 min) |
Window duration in milliseconds |
store |
object |
MemoryStore |
Pluggable store with hit(key, windowMs) → {count, resetMs, resetAt} |
keyGenerator |
function |
defaultKeyGenerator |
(req) => string — client identifier (default: req.socket.remoteAddress) |
Return Value
Section titled “Return Value”Allowed Request
Section titled “Allowed Request”{ response: { headers: [ ['X-RateLimit-Limit', '100'], ['X-RateLimit-Remaining', '99'], ['X-RateLimit-Reset', '1717041600'] ] }}Limited Request
Section titled “Limited Request”{ response: { statusCode: 429, retryAfter: 30, headers: [ ['X-RateLimit-Limit', '100'], ['X-RateLimit-Remaining', '0'], ['X-RateLimit-Reset', '1717041600'] ] }}send() automatically sets the Retry-After header and formats the
RFC 9457 error body.
Error Responses
Section titled “Error Responses”| Status | Condition |
|---|---|
| 429 Too Many Requests | Request count exceeds max within windowMs |
import {compose, rateLimit} from '@centralping/ergo';
const pipeline = compose( rateLimit({max: 100, windowMs: 60_000}), (req, res, acc) => ({ response: {statusCode: 200, body: {ok: true}}, }),);import createRouter from '@centralping/ergo-router';
const router = createRouter({ defaults: { rateLimit: {max: 100, windowMs: 60_000}, },});
// Strict limit on login — 5 attempts per 15 minutesrouter.post('/login', { rateLimit: {max: 5, windowMs: 900_000}, execute: handleLogin,});
// Relaxed limit on reads — 1000 per minuterouter.get('/items', { rateLimit: {max: 1000, windowMs: 60_000}, execute: listItems,});
// Disabled for health checks — no rate limitingrouter.get('/health', { rateLimit: false, authorization: false, execute: (req, res, acc) => ({ response: {body: {status: 'ok'}}, }),});Custom Store
Section titled “Custom Store”The store option accepts any object that implements a single method:
| Method | Signature | Returns |
|---|---|---|
hit |
(key: string, windowMs: number) => object |
{count: number, resetMs: number, resetAt: number} |
| Return Field | Type | Meaning |
|---|---|---|
count |
number |
Total hits recorded for key within the current window |
resetMs |
number |
Milliseconds until the oldest entry in the window expires |
resetAt |
number |
Absolute reset time in milliseconds in the store’s clock domain |
The middleware calls store.hit(key, windowMs) once per request. The
store is responsible for recording the hit, pruning expired entries,
and returning the current state. No other methods are called.
MemoryStore Options
Section titled “MemoryStore Options”The built-in MemoryStore accepts optional configuration:
| Option | Type | Default | Description |
|---|---|---|---|
maxKeys |
number |
10_000 |
Maximum tracked keys before FIFO eviction. When exceeded, the oldest key is silently removed. |
now |
function |
Date.now |
Clock function returning current time in milliseconds. Useful for deterministic testing. |
import {MemoryStore} from '@centralping/ergo/lib/rate-limit';
const store = new MemoryStore({maxKeys: 50_000});MemoryStore Methods
Section titled “MemoryStore Methods”| Method | Returns | Description |
|---|---|---|
hit(key, windowMs) |
{count, resetMs, resetAt} |
Record a hit and return current window state |
reset() |
void |
Clear all tracked keys, restoring the store to its initial state |
reset() is useful for integration tests that share a single store
instance across multiple test cases. Call it between tests to prevent
rate-limit counter accumulation:
import {describe, beforeEach} from 'node:test';import {MemoryStore} from '@centralping/ergo/lib/rate-limit';
const store = new MemoryStore();
describe('my API', () => { beforeEach(() => store.reset());
// tests can now hit the rate-limited endpoint without budget exhaustion});Redis Store Example
Section titled “Redis Store Example”For multi-instance deployments, implement the hit() contract with a
shared data store. Because store.hit() must return synchronously, a
Redis-backed store uses a local sliding-window counter for the
synchronous return value and syncs counts to Redis in the background
for cross-instance visibility. Bound the local map with maxKeys FIFO
eviction (same default as MemoryStore) so high-cardinality keys cannot
grow the process heap unboundedly.
This example uses ioredis with sorted sets:
import Redis from 'ioredis';
class RedisStore { #local = new Map(); #maxKeys;
constructor(redis, {maxKeys = 10_000} = {}) { if (!Number.isInteger(maxKeys) || maxKeys < 1) { throw new TypeError('maxKeys must be a positive integer'); } this.redis = redis; this.#maxKeys = maxKeys; }
hit(key, windowMs) { const now = Date.now(); const cutoff = now - windowMs; const windowKey = `rl:${key}`;
let entries = this.#local.get(windowKey); if (!entries) { entries = []; this.#local.set(windowKey, entries); }
while (entries.length > 0 && entries[0] <= cutoff) entries.shift();
// Delete-then-set refreshes Map insertion order for FIFO eviction if (entries.length === 0) { this.#local.delete(windowKey); entries = [now]; this.#local.set(windowKey, entries); } else { entries.push(now); }
// FIFO eviction — Map insertion order; silent like MemoryStore if (this.#local.size > this.#maxKeys) { const oldest = this.#local.keys().next().value; this.#local.delete(oldest); }
const count = entries.length; const resetMs = entries.length > 0 ? Math.max(0, entries[0] + windowMs - now) : windowMs; const resetAt = now + resetMs;
this.#sync(windowKey, now, cutoff, windowMs);
return {count, resetMs, resetAt}; }
#sync(windowKey, now, cutoff, windowMs) { const pipeline = this.redis.pipeline(); pipeline.zremrangebyscore(windowKey, 0, cutoff); pipeline.zadd(windowKey, now, `${now}:${Math.random()}`); pipeline.pexpire(windowKey, windowMs); pipeline.exec().catch(() => {}); }}When to Use a Shared Store
Section titled “When to Use a Shared Store”| Scenario | Recommended Store |
|---|---|
| Single process, development, or testing | MemoryStore (default) — no external dependencies |
| Multiple instances behind a load balancer | Redis or another shared store — counters must be visible across instances |
| Serverless / ephemeral containers | Shared store — process restarts reset in-memory counters |
Proxy Awareness
Section titled “Proxy Awareness”When deploying behind a reverse proxy, provide a custom keyGenerator
that reads the forwarded client IP:
rateLimit({ keyGenerator: (req) => { const forwarded = req.headers['x-forwarded-for']; return forwarded ? forwarded.split(',')[0].trim() : req.socket.remoteAddress; },})This applies to both pipeline-level rate limiting
(defaults.rateLimit or per-route rateLimit in ergo-router) and
transport-level rate limiting (transport.rateLimit). The
trustProxy router transport option does
not automatically make rate limiting proxy-aware — it only affects
request IDs and HSTS. A custom keyGenerator is always required for
correct per-client rate limiting behind a proxy.
ergo-router Configuration
Section titled “ergo-router Configuration”Precedence
Section titled “Precedence”Per-route rateLimit config follows
Config Resolution semantics:
| Config Level | Behavior |
|---|---|
Route rateLimit: {max, windowMs} |
Overrides defaults for this route (shallow replace) |
Route rateLimit: false |
Disables rate limiting for this route |
Route rateLimit: undefined (omitted) |
Inherits from defaults.rateLimit |
defaults.rateLimit: {max, windowMs} |
Applied to every route that does not override |
defaults.rateLimit: undefined (omitted) |
No pipeline-level rate limiting |
Presets Interaction
Section titled “Presets Interaction”Only presets.public includes
transport-level rate limiting (transport.rateLimit: {} with built-in
defaults of 100 requests per 60 seconds). The jsonApi, sse, and
webhooks presets do not include rate limiting at either level.
To add pipeline-level rate limiting when using a preset:
import createRouter, {presets} from '@centralping/ergo-router';
const router = createRouter({ ...presets.jsonApi, defaults: { ...presets.jsonApi.defaults, rateLimit: {max: 200, windowMs: 60_000}, },});RFC References
Section titled “RFC References”Related Recipes
Section titled “Related Recipes”- Sub-Routers — Per-group rate limiting with transport vs pipeline distinction
- Testing Patterns — Rate-limit assertion patterns and store isolation
- Production Deployment — Redis rate limiting in a Docker deployment with nginx and health checks
API Reference
Section titled “API Reference”See the auto-generated rateLimit API docs.