iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Rate Limiting

Rate limiting protects your APIs from abuse, runaway clients, and brute-force attacks. Redis is the standard backbone: atomic counters, TTLs, and Lua scripts give you fixed-window, sliding-window, leaky-bucket, and token-bucket limiters in a few lines of code.

Fixed window, sliding, token bucket, Lua

EXAMPLE
// 1) Fixed-window — simplest
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

async function fixedWindow(key, limit, windowSec) {
    const bucket = `${key}:${Math.floor(Date.now() / 1000 / windowSec)}`;
    const count = await redis.incr(bucket);
    if (count === 1) await redis.expire(bucket, windowSec);
    return { allowed: count <= limit, current: count, remaining: Math.max(0, limit - count) };
}

// Usage
const r = await fixedWindow(`rl:ip:${req.ip}`, 100, 60);
if (!r.allowed) return res.status(429).set('Retry-After', '60').end();

// Pros: trivial. Cons: 'edge' bursts — a client can do 100 at second 59 and 100 at second 60.

// 2) Sliding-window log — exact but memory-heavy
async function slidingLog(key, limit, windowMs) {
    const now = Date.now();
    const minScore = now - windowMs;
    const member = `${now}-${Math.random()}`;
    const multi = redis.multi();
    multi.zremrangebyscore(key, 0, minScore);
    multi.zadd(key, now, member);
    multi.zcard(key);
    multi.pexpire(key, windowMs);
    const [, , count] = await multi.exec().then((r) => r.map((x) => x[1]));
    return { allowed: count <= limit, current: count };
}

// Stores one entry per request in a sorted set. Best for small-volume per-user limits.

// 3) Sliding-window counter — approximation, lower memory
async function slidingCounter(key, limit, windowSec) {
    const now = Math.floor(Date.now() / 1000);
    const currentBucket = `${key}:${Math.floor(now / windowSec)}`;
    const prevBucket    = `${key}:${Math.floor(now / windowSec) - 1}`;
    const positionInWindow = (now % windowSec) / windowSec;     // 0..1

    const multi = redis.multi();
    multi.incr(currentBucket);
    multi.expire(currentBucket, windowSec * 2);
    multi.get(prevBucket);
    const [curr, , prev] = await multi.exec().then((r) => r.map((x) => x[1]));

    const weighted = Number(prev ?? 0) * (1 - positionInWindow) + Number(curr);
    return { allowed: weighted <= limit, current: Math.round(weighted) };
}

// 4) Token bucket — burst-friendly, classic
const TOKEN_BUCKET = `
local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local state = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts     = tonumber(state[2]) or now
local delta  = math.max(0, now - ts)
tokens = math.min(capacity, tokens + delta * refill)

local allowed = tokens >= cost
if allowed then tokens = tokens - cost end

redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill))
return { allowed and 1 or 0, tokens }
`;

async function tokenBucket(key, capacity, refillPerSec, cost = 1) {
    const [allowed, tokens] = await redis.eval(
        TOKEN_BUCKET, 1, key,
        capacity, refillPerSec, Date.now() / 1000, cost,
    );
    return { allowed: allowed === 1, tokens };
}

// 5) Leaky bucket — queue-style smoothing
const LEAKY = `
local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak     = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local state = redis.call('HMGET', key, 'level', 'ts')
local level = tonumber(state[1]) or 0
local ts    = tonumber(state[2]) or now
level = math.max(0, level - (now - ts) * leak)
local allowed = level + 1 <= capacity
if allowed then level = level + 1 end
redis.call('HMSET', key, 'level', level, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / leak))
return { allowed and 1 or 0, level }
`;

// 6) Express middleware example
function rateLimit({ keyer, limit, windowSec }) {
    return async (req, res, next) => {
        const key = `rl:${keyer(req)}`;
        const r = await fixedWindow(key, limit, windowSec);
        res.setHeader('X-RateLimit-Limit', limit);
        res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - r.current));
        if (!r.allowed) return res.status(429).setHeader('Retry-After', windowSec).end('Too Many Requests');
        next();
    };
}

app.use(rateLimit({
    keyer: (req) => `ip:${req.ip}`,
    limit: 100, windowSec: 60,
}));

// 7) Per-user vs per-IP vs per-route
// Layer multiple limits:
//   • Per-IP global       — 100/min
//   • Per-user authed     — 1000/min
//   • Per-route auth      — 5/min on /login
//   • Per-tenant          — by API key
// Each layer can fail independently; first to deny wins.

// 8) Communicating limits to clients
// Standard headers:
//   X-RateLimit-Limit       100
//   X-RateLimit-Remaining   87
//   X-RateLimit-Reset       1716937800        (epoch seconds)
//   Retry-After              30                (seconds OR HTTP-date)
// Use RFC 9110 'Retry-After' for 429 + 503.

// 9) Distributed safety — single Redis vs cluster
// • Use one Redis cluster for global limits
// • If using cluster mode, ensure all keys for a limiter hash to the SAME slot via hash tags
//   e.g. rl:{userId}:fixed   rl:{userId}:sliding
// • Otherwise multi/EVAL across slots fails with CROSSSLOT

// 10) Local fallback when Redis is down
// • Decide policy: 'fail open' (allow) or 'fail closed' (deny)
// • For abuse-prevention limits → fail open with a local lower limit
// • For paid-tier quotas → fail closed (don't give away free service)

function safeLimit(fn, fallbackAllow) {
    return async (...args) => {
        try { return await fn(...args); }
        catch (e) {
            log.warn('rate-limit redis down', e);
            return { allowed: fallbackAllow, current: 0 };
        }
    };
}

// 11) Libraries to consider
// • rate-limiter-flexible (Node) — production-ready, multiple backends
// • express-rate-limit + rate-limit-redis (Node)
// • django-ratelimit / django-redis (Python)
// • limits (Python, FastAPI/Starlette)
// • Sidekiq Throttle (Ruby)
// • Bucket4j (Java)
// • Most production stacks ship behind an API gateway (Kong, Envoy) that handles rate limiting

// 12) Common bugs
// • TTL not set on counter key → memory leak; always EXPIRE
// • Atomicity broken in multi-step logic → use Lua script
// • Using ip-based limit behind a proxy — read trust-proxy headers (X-Forwarded-For) correctly
// • Limit too low for legit batch endpoints → endpoints need different limits
// • 429 response without Retry-After → clients spam retries
// • Counters reset on Redis restart — accept the drift or use AOF persistence
// • Per-key memory unbounded — set sensible TTLs + monitor key counts
// • Banning IP forever on rate-limit hit → use temporary block, allow recovery
// • Sliding-log on a million users — too much memory; switch to sliding-counter or token bucket

Why it matters

Redis is the natural place for rate limiting: atomic counters with TTLs for fixed/sliding windows, Lua scripts for token-bucket and leaky-bucket algorithms, and stable headers (X-RateLimit-* + Retry-After) so clients can back off. Layer per-IP + per-user + per-route limits, decide your fail-open/fail-closed policy, and always set a TTL so memory stays bounded.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
# Fixed window: 100 req / min per IP
key = "rl:" + ip + ":" + minute
INCR $key
EXPIRE $key 60
# Block if value > 100
Try it Yourself »

Discussion

Loading…