Sin rate limiting, cualquier cliente abre la API a miles de requests por segundo y se lleva al servidor. Con un límite mal calibrado, cortas a un cliente que paga en medio del checkout. Un rate limiting bien dimensionado protege el backend y avisa al cliente antes de rechazar.
Los algoritmos y lo que mide cada uno
Fixed Window
Cuenta requests dentro de una ventana fija, 100 por minuto por ejemplo. El borde crea el problema del edge burst: 100 requests en el segundo 59 de la ventana más 100 en el segundo 0 de la siguiente pasan en dos segundos.
Sliding Window Log
Guarda el timestamp de cada request y cuenta solo los de la ventana corrente. Preciso en cualquier punto del borde, y el consumo de memoria crece con el volumen de requests.
Token Bucket
El bucket se llena a tasa fija y cada request gasta un token. Los tokens disponibles permiten el burst y el refill controla el techo promedio por período. El RFC 2697 (Heinanen y Baker, 1999) describe el marcador de tres colores construido sobre un token bucket, y los gateways de API copiaron ese diseño.
// Token bucket con Redis
async function isRateLimited(
key: string,
maxTokens: number,
refillRate: number
): Promise<boolean> {
const now = Date.now()
const bucket = await redis.hgetall(`ratelimit:${key}`)
if (!bucket.tokens) {
// El primer request crea el bucket
await redis.hset(`ratelimit:${key}`, {
tokens: maxTokens - 1,
lastRefill: now
})
await redis.expire(`ratelimit:${key}`, 60)
return false
}
// Repone tokens según el tiempo transcurrido
const elapsed = now - Number(bucket.lastRefill)
const refill = Math.floor(elapsed / 1000 * refillRate)
const tokens = Math.min(maxTokens, Number(bucket.tokens) + refill)
if (tokens <= 0) return true
await redis.hset(`ratelimit:${key}`, {
tokens: tokens - 1,
lastRefill: now
})
return false
}
La documentación de Redis trae una receta de rate limiter con sorted sets, que cubre la ventana deslizante sin escanear logs viejos.
La clave decide quién recibe el límite
- Por IP: frena bots y abuso anónimo. CGNAT y VPN ponen a usuarios legítimos en el mismo balde.
- Por usuario autenticado: el límite acompaña el plan que el cliente paga.
- Por API key: un bucket por desarrollador en una API pública. El abuso de una cuenta no tumba a las demás.
- Compuesto: un techo por IP encima de un techo por usuario. DDoS y abuso de cuenta atendidos en una sola pasada.
Headers que el cliente lee antes de insistir
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 23
X-RateLimit-Reset: 1711234567
Retry-After: 30 // presente en el 429
X-RateLimit-* se volvió costumbre de mercado por la documentación de la API de Twitter y sigue sin RFC detrás. El IETF está estandarizando el conjunto de campos en draft-ietf-httpapi-ratelimit-headers (Polli y Martinez, IETF HTTPAPI WG), que define RateLimit y RateLimit-Policy con semántica explícita de ventana. El RFC 6585 (Nottingham y Fielding, 2012) creó el status 429 y prevé Retry-After en la respuesta. Sin ese header, el cliente adivina el tiempo de espera y entra en loop de retry contra tu propio rate limit.
express-rate-limit con store en Redis
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
keyGenerator: (req) => req.user?.id || req.ip,
handler: (req, res) => {
res.status(429).json({
error: {
code: 'RATE_LIMITED',
message: 'Demasiadas requests. Intenta de nuevo en unos segundos.',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
}
})
}
})
app.use('/api/', limiter)
El keyGenerator devuelve el id del usuario cuando existe sesión y cae al IP en el tráfico anónimo. Un store en Redis comparte el contador entre instancias; el store en memoria hace que cada pod cuente solo, y el límite se vuelve max multiplicado por el número de pods. La documentación de express-rate-limit detalla standardHeaders y las advertencias de proxies detrás de un load balancer.
El límite por ruta sigue el costo de la ruta
Login corre bcrypt y cuesta milissegundos por intento; el listado corre un índice y cuesta microsegundos. Un límite único para los dos castiga el flujo barato.
// Login: 5 intentos por minuto frena brute force
app.use('/api/auth/login', rateLimit({ windowMs: 60000, max: 5 }))
// Listados: 100 por minuto
app.use('/api/products', rateLimit({ windowMs: 60000, max: 100 }))
// Upload: 10 por hora
app.use('/api/upload', rateLimit({ windowMs: 3600000, max: 10 }))
Cuando el límite cae encima del cliente
Un 429 con Retry-After es el piso del contrato. Encima, la degradación elegante devuelve una versión en cache o datos parciales cuando existen, en vez de un error puro. El capítulo 21, Handling Overload, del Google SRE Book (2016) recuerda que rechazar un request también consume recursos del servidor y que las respuestas degradadas mantienen el servicio de pie durante el pico. El cliente recibe algo usable y el backend conserva CPU para el tráfico que pasa.
Referencias
- RFC 6585, 429 Too Many Requests (Nottingham y Fielding, 2012)
- draft-ietf-httpapi-ratelimit-headers, RateLimit header fields for HTTP (Polli y Martinez, IETF HTTPAPI WG)
- RFC 2697, A Single Rate Three Color Marker (Heinanen y Baker, 1999)
- Site Reliability Engineering, cap. 21, Handling Overload (Google, O'Reilly, 2016)
- Redis docs: rate limiter y express-rate-limit
¿Te gustó el contenido?
Construyo productos web y soluciones con IA de la manera correcta — arquitectura sólida, código sostenible y entrega real.
Hablemos