Skip to content

Latest commit

 

History

28 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

API Rate Limiter

Distributed rate limiting for Express, using a token bucket over Redis or memory.

Isometric map of the rate-limiter runtime: thirteen modules as buildings, tracing a distributed request over Redis from validation through the middleware, identity, the Redis store, the Lua script, Redis itself, and back out through the response and metrics.

One request, distributed over Redis, traced module by module: trace it yourself through any of the six real flows, or read architecture.md for why it is built this way and what breaks at each scale.


How It Works

Every client holds a bucket of tokens. Tokens accrue continuously at requestsPerMinute / 60 per second and are capped at capacity, which is what makes bursts possible. A request costs one token; no token, no request.

1. Request hits the middleware
2. Resolve the client identifier (IP, API key, ...)
3. Read the bucket for (scope, identifier)
4. Add the tokens accrued since the last read
5. Try to spend one
6. Success -> next()      Failure -> 429 with Retry-After

Nothing runs on a timer: the bucket is a token count plus the timestamp it was last touched, so its current state is always derivable on read.

Every server pointed at the same Redis shares one bucket per (scope, identifier), so limits hold across the fleet rather than per process. The whole read-refill-consume cycle runs inside one Lua script, so concurrent servers cannot double-spend a token, and the script reads the clock from Redis rather than from the caller. Buckets are keyed ratelimit:<scope>|<identifier>, one bucket per limiter rather than one per client, with a TTL derived from each bucket's refill time. The reasoning behind each of those.

Redis Cluster

Pass rootNodes instead of host/port/url and RedisStore switches to redis.createCluster:

const store = new RedisStore({
  rootNodes: [
    { url: 'redis://10.0.0.1:6379' },
    { url: 'redis://10.0.0.2:6379' },
    { url: 'redis://10.0.0.3:6379' }
  ]
});

checkLimit is slot-safe for free: every call touches exactly one key, so the cluster client routes it to the right shard with no extra work. Script loading, getStats(), resetAll() and multi-key DEL all had to be built to fan out across masters, and a single master going down degrades only the slots it owns. What cluster mode changes, and what it costs.

Covered by tests/redisCluster.test.js against a live 3-node cluster; see Testing for how to point the suite at one.


When Redis is unavailable

A rate limiter sits in front of every request, so its failure mode is the service's failure mode. This one is explicit:

  • Commands are not queued while the socket is down, so they fail immediately instead of piling up.
  • Every check is raced against commandTimeoutMs (default 250ms).
  • Connection attempts are rate limited to one per reconnectCooldownMs, so a dead Redis costs one attempt per second rather than one per request.
  • failOpen decides what happens next: true (default) allows the request, false rejects it with Retry-After: 1. Both paths are counted in store.metrics (degradedAllowed / degradedDenied), and repeated errors are logged once per window instead of once per request.
  • /health reports 503 while Redis is unreachable, so a load balancer can route around the instance.

Measured on this repo with Redis killed mid-traffic: requests complete in 1-2ms on the degraded path, and enforcement resumes on its own once Redis returns. Choosing between fail-open and fail-closed, and the full failure table.


Measured cost

$ BENCH_REQUESTS=3000 npm run bench        # node v22.18.0, macOS, local Redis

configuration       rps       p50 ms    p95 ms    p99 ms    added p50
no rate limiting    1487      0.50      0.84      2.04      +0.00 ms
MemoryStore         1596      0.53      0.89      2.46      +0.02 ms
RedisStore          1039      0.89      1.39      3.25      +0.38 ms

Reproduce with npm run bench; the harness is tests/bench.js. Read these as a delta, not a service level: it is loopback traffic, the handler does no work, and the load generator shares the event loop with the server (which is why concurrency defaults to 1 - it is the only setting where the numbers isolate per-request cost). On a real handler the limiter's share is proportionally much smaller.


Installation

Quick start (in-memory)

git clone <your-fork-url> rate-limiter
cd rate-limiter
npm install

npm start

# 5 allowed, then 429
for i in $(seq 1 7); do curl -s -o /dev/null -w '%{http_code} ' http://127.0.0.1:3000/api/expensive; done

Production (Redis)

brew install redis          # macOS
# sudo apt-get install redis-server   # Debian/Ubuntu

redis-server &

ADMIN_TOKEN=$(openssl rand -hex 24) npm run start:redis

# second instance, same Redis, shared limits
PORT=3001 SERVER_ID=server2 npm run start:redis

curl http://127.0.0.1:3000/api/expensive
curl http://127.0.0.1:3001/api/expensive   # spends from the same bucket

Requires Node 18.17 or newer.


Usage

Basic Express integration

const rateLimiter = require('rate-limiter');

// Aggregate net. Mounted before the routes, or it only ever runs on 404s.
app.use(rateLimiter({ requestsPerMinute: 300, scope: 'global' }));

// Per-route limit. A request passing through both limiters spends a token in
// each, so keep the aggregate looser than anything it sits above.
app.post('/api/expensive', rateLimiter({ requestsPerMinute: 5, scope: 'expensive' }), handler);

Limiters that do not bring their own store share one in-memory store, so ten limiters cost one cleanup timer rather than ten.

With Redis (multi-server)

const { createRateLimiter, RedisStore } = require('rate-limiter');

const store = new RedisStore({
  url: process.env.REDIS_URL,       // or host/port/password
  failOpen: true,                   // allow traffic if Redis is down
  commandTimeoutMs: 250
});
await store.connect();              // optional; the first request connects too

app.use(createRateLimiter({ requestsPerMinute: 100, scope: 'global', store }));

// on SIGTERM
await store.close();

Custom identifiers (API keys)

const known = new Set(await loadApiKeys());

app.use(rateLimiter({
  requestsPerMinute: 10,
  scope: 'api-key',
  // Validate before trusting: an unvalidated header means a client can mint a
  // fresh bucket per request just by changing it.
  identifier: (req) => {
    const key = req.headers['x-api-key'];
    return known.has(key) ? `key:${key}` : req.ip;
  }
}));

Behind a proxy or load balancer

req.ip is the socket address unless Express is told otherwise, so behind a proxy every client collapses into the proxy's bucket:

app.set('trust proxy', 1);   // number of proxies you actually control

Only do this if the proxy overwrites X-Forwarded-For rather than appending to a client-supplied value, otherwise the header becomes a free bypass. The middleware logs a warning once if it sees X-Forwarded-For while trust proxy is unset. Both failure modes, in detail.


Configuration options

Option Type Default Description
requestsPerMinute number 60 Sustained rate. 0 rejects everything.
capacity number requestsPerMinute Burst size (max tokens held).
cost number 1 Tokens spent per request. Must not exceed capacity.
scope string hash of the limits Bucket namespace. Set it explicitly for readable Redis keys.
store object shared MemoryStore MemoryStore or RedisStore.
identifier function (req) => req.ip Client identity. Return a falsy value to skip.
skip function () => false Bypass condition.
handler function null Custom rejection response.
headers boolean true Emit the RateLimit-* / X-RateLimit-* headers.
message string "Too many requests..." Message in the default 429 body.
statusCode number 429 Rejection status.
logger object console Anything with warn/error/log.

Invalid configuration throws at wire-up rather than misbehaving per request: NaN, negative values, a cost above capacity, a malformed scope, or a store missing checkLimit are all rejected immediately.

RedisStore options

Option Default Description
url / host / port 127.0.0.1:6379 url wins when both are given. Ignored in cluster mode.
rootNodes unset Array of { url } or { host, port }. Presence switches to Redis Cluster mode (redis.createCluster); see Redis Cluster.
username / password / db - Passed through to node-redis. db is not supported in cluster mode (Redis Cluster is always db 0).
socket {} Merged into the socket options (TLS, etc.).
keyPrefix ratelimit: Key namespace.
failOpen true Allow (true) or reject (false) when Redis is unusable.
commandTimeoutMs 250 Per-request budget for the Lua call.
connectTimeoutMs 1000 Connect / SCRIPT LOAD budget.
reconnectCooldownMs 1000 Minimum gap between connect attempts.
minTtlSeconds / maxTtlSeconds / ttlPaddingSeconds 60 / 86400 / 60 Bounds for the derived key TTL.
degradedRetryAfterMs 1000 Retry-After used when failing closed.
logIntervalMs 5000 Error-log throttle window.

MemoryStore options

Option Default Description
maxClients 100000 Hard bound on tracked clients (LRU eviction beyond it).
cleanupIntervalMs 600000 Sweep period. The timer is unref()d.
inactiveAfterMs 3600000 Idle age at which a bucket is dropped.
autoCleanup true Set false to sweep manually.

Response headers

Header Meaning
RateLimit-Limit / X-RateLimit-Limit Bucket capacity in force
RateLimit-Remaining / X-RateLimit-Remaining Whole tokens left
RateLimit-Reset Seconds until the bucket is back at capacity
X-RateLimit-Reset The same instant as an ISO-8601 timestamp
Retry-After Seconds until the next token, on rejection only. Never 0, and omitted entirely when waiting cannot help (a limiter that never refills).

Both storage backends produce identical values for the same state.


Admin endpoints (Redis demo)

They only exist when ADMIN_TOKEN is set (16+ characters), and they require a bearer token compared in constant time. reset clears a client's limit and stats enumerates buckets, so leaving either open defeats the limiter.

export ADMIN_TOKEN=$(openssl rand -hex 24)

# counts and per-scope cardinality; identifiers are never returned in the clear
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:3000/api/admin/stats

# hashed identifiers, if you need to correlate without exposing API keys
curl -H "Authorization: Bearer $ADMIN_TOKEN" 'http://127.0.0.1:3000/api/admin/stats?identifiers=hashed'

# clear one scope, or every scope for that client
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  'http://127.0.0.1:3000/api/admin/reset/1.2.3.4?scope=expensive'

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  'http://127.0.0.1:3000/api/admin/bucket/1.2.3.4?scope=expensive'

GET /health needs no token and reports Redis reachability, latency, and the failOpen setting.


Testing

npm test                                                                          # unit + integration
TEST_REDIS_URL=redis://127.0.0.1:6379 npm test                                    # + the Redis suite
TEST_REDIS_CLUSTER_NODES=127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002 npm test    # + the cluster suite
npm run bench                                                                      # latency harness

54 tests over the bucket arithmetic, both storage backends, and the middleware. The Redis- and cluster-backed tests skip themselves when their target isn't reachable rather than failing. Covered explicitly, because each one was once broken:

  • exhausting a strict endpoint must not affect a relaxed one
  • rotating an unrecognised API key must not mint quota
  • requestsPerMinute: 0 must reject without advertising an impossible retry
  • a throwing identifier/skip/store must become a 500, never a hung request
  • an unreachable Redis must answer in bounded time, both fail-open and fail-closed
  • waiting exactly Retry-After must be enough to succeed
  • a clock moving backwards must neither mint nor destroy tokens
  • stats must never return identifiers in the clear
  • on a cluster, a downed master must only degrade the slots it owns
  • on a cluster, EVALSHA must not NOSCRIPT on a master the client didn't happen to route to first

tests/tests.txt is the manual curl runbook, including the multi-server checks.


Environment variables (demos)

Variable Default Applies to
PORT 3000 both
ADMIN_TOKEN unset both - admin routes and the bypass are disabled without it
API_KEYS demo-key-1,demo-key-2 both - comma-separated allowlist
LOG_CLIENT_IPS unset both - log raw IPs instead of hashes
REDIS_URL / REDIS_HOST / REDIS_PORT / REDIS_PASSWORD 127.0.0.1:6379 Redis demo
FAIL_OPEN 1 Redis demo - 0 rejects while Redis is down
REQUIRE_REDIS unset Redis demo - 1 refuses to start without Redis
SERVER_ID primary Redis demo - identifies the instance in responses

Both demos drain on SIGTERM and SIGINT: the listener stops, in-flight requests get 10 seconds, then Redis is closed and the process exits 0.


What this project taught me

  • Atomicity is the easy part. The hard parts are the key design around the script, whose clock the arithmetic trusts, and what happens when the dependency is gone.
  • A limiter's error path is a security control: fail-open during an outage means no limits at all, so it has to be a deliberate, visible choice.
  • Any identity a client can choose is not an identity. Validate it, or bound the damage.
  • Admin endpoints on a rate limiter are the rate limiter. Unauthenticated reset is a bypass, and unauthenticated stats leaks whatever you used as an identifier.

All four are worked through, with the code that implements them, in architecture.md.


License

MIT - see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages