|$ curl https://forge-ai.dev/api/markdown?path=docs/security/rate-limiting
$cat docs/rate-limiting.md
updated Recently·30 min read·published
Rate Limiting
Introduction
Rate limiting controls how many requests a client can make within a time window. It protects APIs from abuse, DDoS attacks, and accidental overload. Without rate limiting, a single misbehaving client can take down your entire service.
Rate Limiting Algorithms
| Algorithm | How It Works | Pros |
|---|---|---|
| Fixed Window | Count requests in fixed time windows | Simple, low memory |
| Sliding Window | Weighted average of current + previous window | Smooths boundary spikes |
| Token Bucket | Tokens refill at steady rate; each request costs a token | Allows controlled bursts |
| Leaky Bucket | Requests queue; processed at fixed rate | Perfectly smooth output |
token-bucket.ts
TypeScript
| 1 | // Token Bucket algorithm implementation |
| 2 | class TokenBucket { |
| 3 | private tokens: number; |
| 4 | private lastRefill: number; |
| 5 | |
| 6 | constructor( |
| 7 | private readonly capacity: number, |
| 8 | private readonly refillRate: number, // tokens per second |
| 9 | ) { |
| 10 | this.tokens = capacity; |
| 11 | this.lastRefill = Date.now(); |
| 12 | } |
| 13 | |
| 14 | consume(tokens: number = 1): boolean { |
| 15 | this.refill(); |
| 16 | if (this.tokens >= tokens) { |
| 17 | this.tokens -= tokens; |
| 18 | return true; |
| 19 | } |
| 20 | return false; |
| 21 | } |
| 22 | |
| 23 | private refill() { |
| 24 | const now = Date.now(); |
| 25 | const elapsed = (now - this.lastRefill) / 1000; |
| 26 | this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate); |
| 27 | this.lastRefill = now; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | // Usage: 100 requests per minute, burst of 10 |
| 32 | const bucket = new TokenBucket(10, 100 / 60); |
| 33 | |
| 34 | function handleRequest(req: Request) { |
| 35 | if (!bucket.consume()) { |
| 36 | return new Response("Rate limit exceeded", { |
| 37 | status: 429, |
| 38 | headers: { |
| 39 | "Retry-After": "60", |
| 40 | "X-RateLimit-Limit": "100", |
| 41 | "X-RateLimit-Remaining": "0", |
| 42 | }, |
| 43 | }); |
| 44 | } |
| 45 | // Process request... |
| 46 | } |
Redis-Based Rate Limiting
redis-ratelimit.ts
TypeScript
| 1 | // Redis sliding window rate limiter |
| 2 | import Redis from "ioredis"; |
| 3 | |
| 4 | const redis = new Redis(process.env.REDIS_URL); |
| 5 | |
| 6 | async function isRateLimited( |
| 7 | key: string, |
| 8 | limit: number, |
| 9 | windowMs: number, |
| 10 | ): Promise<{ allowed: boolean; remaining: number }> { |
| 11 | const now = Date.now(); |
| 12 | const windowStart = now - windowMs; |
| 13 | const multi = redis.multi(); |
| 14 | |
| 15 | // Remove old entries |
| 16 | multi.zremrangebyscore(key, 0, windowStart); |
| 17 | // Add current request |
| 18 | multi.zadd(key, now, `${now}-${Math.random()}`); |
| 19 | // Count requests in window |
| 20 | multi.zcard(key); |
| 21 | // Set expiry |
| 22 | multi.pexpire(key, windowMs); |
| 23 | |
| 24 | const results = await multi.exec(); |
| 25 | const count = results![2][1] as number; |
| 26 | |
| 27 | return { |
| 28 | allowed: count <= limit, |
| 29 | remaining: Math.max(0, limit - count), |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | // Next.js API route middleware |
| 34 | export async function rateLimit(request: Request, limit = 100, windowMs = 60000) { |
| 35 | const ip = request.headers.get("x-forwarded-for") || "unknown"; |
| 36 | const key = `ratelimit:${ip}`; |
| 37 | |
| 38 | const { allowed, remaining } = await isRateLimited(key, limit, windowMs); |
| 39 | |
| 40 | if (!allowed) { |
| 41 | return new Response(JSON.stringify({ error: "Rate limit exceeded" }), { |
| 42 | status: 429, |
| 43 | headers: { |
| 44 | "Content-Type": "application/json", |
| 45 | "X-RateLimit-Limit": String(limit), |
| 46 | "X-RateLimit-Remaining": "0", |
| 47 | "Retry-After": String(Math.ceil(windowMs / 1000)), |
| 48 | }, |
| 49 | }); |
| 50 | } |
| 51 | return null; // Allowed |
| 52 | } |
ℹ
info
Use Redis MULTI (transactions) to ensure atomicity. Without it, race conditions can cause incorrect rate limit counts under high concurrency.
Per-User & Tiered Limits
per-user-limits.ts
TypeScript
| 1 | // Different rate limits per user tier |
| 2 | interface RateLimitConfig { |
| 3 | requests: number; |
| 4 | windowMs: number; |
| 5 | } |
| 6 | |
| 7 | const TIER_LIMITS: Record<string, RateLimitConfig> = { |
| 8 | free: { requests: 60, windowMs: 60_000 }, // 60/min |
| 9 | pro: { requests: 600, windowMs: 60_000 }, // 600/min |
| 10 | enterprise: { requests: 6000, windowMs: 60_000 }, // 6000/min |
| 11 | }; |
| 12 | |
| 13 | async function rateLimitForUser(request: Request) { |
| 14 | const user = await authenticateUser(request); |
| 15 | const tier = user?.tier || "anonymous"; |
| 16 | const config = tier === "anonymous" |
| 17 | ? { requests: 20, windowMs: 60_000 } |
| 18 | : TIER_LIMITS[tier]; |
| 19 | |
| 20 | const key = `ratelimit:${tier}:${user?.id || getIP(request)}`; |
| 21 | return isRateLimited(key, config.requests, config.windowMs); |
| 22 | } |
| 23 | |
| 24 | // Endpoint-specific limits |
| 25 | const ENDPOINT_LIMITS: Record<string, number> = { |
| 26 | "/api/auth/login": 5, // 5 login attempts per minute |
| 27 | "/api/search": 30, // 30 searches per minute |
| 28 | "/api/upload": 10, // 10 uploads per minute |
| 29 | "/api/export": 3, // 3 exports per minute |
| 30 | }; |
Rate Limit Headers
headers.ts
TypeScript
| 1 | // Standard rate limit response headers |
| 2 | // X-RateLimit-Limit: Maximum requests per window |
| 3 | // X-RateLimit-Remaining: Requests remaining |
| 4 | // X-RateLimit-Reset: Unix timestamp when window resets |
| 5 | // Retry-After: Seconds to wait (on 429) |
| 6 | |
| 7 | function rateLimitHeaders(limit: number, remaining: number, resetMs: number) { |
| 8 | return { |
| 9 | "X-RateLimit-Limit": String(limit), |
| 10 | "X-RateLimit-Remaining": String(remaining), |
| 11 | "X-RateLimit-Reset": String(Math.ceil(resetMs / 1000)), |
| 12 | }; |
| 13 | } |
| 14 | |
| 15 | // RFC 6585 compliant 429 response |
| 16 | function tooManyRequests(retryAfter: number) { |
| 17 | return new Response( |
| 18 | JSON.stringify({ error: "Too Many Requests", retryAfter }), |
| 19 | { |
| 20 | status: 429, |
| 21 | headers: { |
| 22 | "Content-Type": "application/json", |
| 23 | "Retry-After": String(retryAfter), |
| 24 | ...rateLimitHeaders(100, 0, Date.now() + retryAfter * 1000), |
| 25 | }, |
| 26 | } |
| 27 | ); |
| 28 | } |
$Blueprint — Engineering Documentation·Section ID: SEC-RL-01·Revision: 1.0