Challenge 09 — Leaky Bucket (Rate Limiter)
🇧🇷 Rate Limiter Distribuído
🇬🇧 Distributed Rate Limiter
Your API is live. Suddenly, 10 thousand requests per second. What happens? Your server dies, the database locks up, and you lose customers.
Rate limiting isn't optional. It's what separates a robust API from one that crashes on Black Friday.
The Leaky Bucket is one of the most widely used algorithms: water comes in at a variable rate (the requests) and drains at a constant rate (the processing). If the bucket fills up, the next drops spill over (429 Too Many Requests).
Architecture
Leaky Bucket (capacity 100, refill 10/s):
┌──────────────────────────────────────────────┐
│ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ │
│ │R│ │R│ │R│ │R│ │R│ → processing │
│ │e│ │e│ │e│ │e│ │e│ (10 req/s) │
│ │q│ │q│ │q│ │q│ │q│ │
│ └─┘ └─┘ └─┘ └─┘ └─┘ │
│ ──────────────────────────────────────── │
│ Overflow (429) │
└──────────────────────────────────────────────┘TypeScript Implementation
Redis Middleware
typescript
import Redis from 'ioredis';
class LeakyBucket {
private redis: Redis;
constructor() {
this.redis = new Redis(process.env.REDIS_URI);
}
async checkLimit(key: string, capacity: number, refillRate: number, refillMs: number) {
const now = Date.now();
const redisKey = `leaky:${key}`;
// Lua script runs atomically in Redis
const result = await this.redis.eval(`
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local interval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last = tonumber(bucket[2]) or now
-- Time-based refill
local elapsed = now - last
if elapsed > 0 then
local add = math.floor(elapsed / interval) * refill
tokens = math.min(capacity, tokens + add)
end
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, interval * 2)
return {1, tokens, 0}
else
return {0, 0, interval - (now % interval)}
end
`, 1, redisKey, capacity, refillRate, refillMs, now);
return {
allowed: result[0] === 1,
remaining: result[1],
resetIn: result[2],
};
}
}
// Express/Fastify middleware
function rateLimit(capacity: number, refillRate: number) {
const bucket = new LeakyBucket();
return async (req: any, res: any, next: any) => {
const key = `${req.ip}:${req.route.path}`;
const result = await bucket.checkLimit(key, capacity, refillRate, 1000);
res.setHeader('X-RateLimit-Limit', capacity);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetIn / 1000));
if (!result.allowed) {
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: Math.ceil(result.resetIn / 1000),
});
}
next();
};
}Go Implementation
go
package main
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
type RateLimiter struct {
rdb *redis.Client
}
func NewRateLimiter(addr string) *RateLimiter {
return &RateLimiter{
rdb: redis.NewClient(&redis.Options{Addr: addr}),
}
}
func (rl *RateLimiter) Check(ctx context.Context, key string,
capacity, refillRate int, refillMs int64) (bool, int, int64) {
now := time.Now().UnixMilli()
script := redis.NewScript(`
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local interval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last = tonumber(bucket[2]) or now
local elapsed = now - last
if elapsed > 0 then
local add = math.floor(elapsed / interval) * refill
tokens = math.min(capacity, tokens + add)
end
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, interval * 2)
return {1, tokens, 0}
else
return {0, 0, interval - (now % interval)}
end
`)
result, err := script.Run(ctx, rl.rdb, []string{key},
capacity, refillRate, refillMs, now).Result()
if err != nil {
return true, capacity, 0 // Fail open
}
vals := result.([]interface{})
allowed := vals[0].(int64) == 1
remaining := int(vals[1].(int64))
resetIn := vals[2].(int64)
return allowed, remaining, resetIn
}
func (rl *RateLimiter) Middleware(capacity, refill int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.RemoteAddr + ":" + r.URL.Path
allowed, remaining, resetIn := rl.Check(r.Context(), key, capacity, refill, 1000)
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(capacity))
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", resetIn/1000))
if !allowed {
w.Header().Set("Retry-After", fmt.Sprintf("%d", resetIn/1000))
http.Error(w, `{"error":"Too Many Requests"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}Testing
bash
make infra-up
pnpm --filter @banking/leaky-bucket dev
# Load test
npx autocannon -c 100 -d 10 http://localhost:3009/api/test
# Check headers
curl -v http://localhost:3009/api/test 2>&1 | grep RateLimitLessons Learned
- Lua script in Redis is atomic — No race conditions. Millions of concurrent requests won't break the bucket.
- Fail open vs fail closed — If Redis goes down, does your API stop? Depends. Finance: fail closed. Social network: fail open.
- Headers are contract —
X-RateLimit-Limit,Remaining,Resetaren't optional. The client needs to know when it can try again. - Capacity and refill are different — Capacity is the burst. Refill is the sustainable rate. One without the other doesn't make sense.