Construyendo un limitador de tasa
Un endpoint, y un script que le pega quince veces seguidas.
app.get('/api/data', (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})Quince solicitudes, quince 200. Nada está contando.
Poniendo un limitador al frente
express-rate-limit es un middleware, así que se ubica entre la ruta y el handler y decide si el handler llega a ejecutarse siquiera.
import { rateLimit } from 'express-rate-limit'
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
})
app.get('/api/data', limiter, (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})Tres configuraciones: cuántas, en qué ventana de tiempo en milisegundos, y qué decir al rechazar. Corre las quince solicitudes de nuevo y las primeras cinco vuelven con 200, y el resto con 429 Too Many Requests.
Esto no es exactamente una ventana fija pura
En el algoritmo, las ventanas avanzan según un horario, lleguen solicitudes o no. Este paquete documenta windowMs como una ventana que empieza en la primera solicitud del cliente, y luego reinicia su contador cuando esa ventana termina.
Llamémosla una ventana de inicio perezoso. El pico en el límite de la ventana sigue existiendo, solo que ahora ese límite está donde cada cliente empezó a pedir, en lugar de ser un punto compartido en el reloj.
El middleware también adjunta su estado a la solicitud:
req.rateLimit // { limit, remaining, reset, used }Es útil para depurar, y es el lugar equivocado para que un cliente lea esta información.
No cambias el handler en absoluto. Cambias lo que tiene que pasar antes.
Diciéndole al cliente cuál es su situación
La información sobre el límite de tasa va en los headers. El JSON es para lo que la aplicación devuelve; los headers son para información sobre el intercambio en sí, junto con el tipo de contenido y los códigos de estado.
El argumento práctico es más fuerte que el de prolijidad. Sin headers, un cliente descubre el límite chocando contra él. Con ellos, cada respuesta dice cuánto presupuesto queda, así que un cliente bien escrito puede frenar antes de que lo rechacen.
Eso importa en las APIs pagas, donde una solicitud desperdiciada cuesta dinero, y hace posible depurar sin tener que analizar los cuerpos de las respuestas.
Hay dos formatos, y ambos aparecen en la práctica:
| Época | Headers |
|---|---|
| Antiguo | X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset |
| Estándar, desde alrededor de 2021 | RateLimit-Limit, RateLimit-Policy, RateLimit-Remaining, RateLimit-Reset |
El paquete todavía usa el conjunto antiguo por defecto, así que pide el moderno explícitamente:
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
standardHeaders: 'draft-6',
legacyHeaders: false,
})Ahora cada respuesta lleva la situación del cliente:
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60Con esto en su lugar, req.rateLimit desaparece del cuerpo del JSON. Los headers hacen el trabajo como corresponde.
Un cliente puede ver cómo baja Remaining y frenar antes de que llegue a cero, en lugar de descubrir el límite chocando de frente contra él.
Viendo cómo se reinicia
Quince solicitudes disparadas en un segundo caen todas dentro de una sola ventana, así que ves cinco éxitos y diez rechazos y nunca ves un reinicio.
Reduce la ventana y ralentiza al cliente, y el ciclo se vuelve visible. Un límite de 3 cada 10 segundos, con 2 segundos entre solicitudes:
| Solicitudes | Resultado |
|---|---|
| 1 a 3 | 200, con Remaining contando 2, 1, 0 |
| 4 y 5 | 429, Reset con la cuenta regresiva |
| 6 a 8 | 200 de nuevo, la ventana se reinició |
| 9 y 10 | 429 |
Ese es todo el comportamiento del algoritmo en una sola corrida: una ráfaga permitida, un muro, un reinicio, otra ráfaga. Lo cual también deja ver el problema del límite de la ventana, ya que tres solicitudes justo antes de un reinicio y tres justo después dejan pasar seis en un instante.
Con dos segundos entre solicitudes puedes ver el reinicio en acción, y esa es la parte que realmente explica el comportamiento.
Ponlo a prueba
Una API está limitada a 100 solicitudes por minuto, y los clientes siguen reportando que las solicitudes fallan sin ningún aviso.
const limiter = rateLimit({ limit: 100, windowMs: 60000 })
app.use(limiter)
app.get('/api/data', (req, res) => {
res.json({ data, rateLimit: req.rateLimit })
})Encuentra tres problemas.
Compara tus respuestas
1. No hay headers estándar. Con standardHeaders sin definir, el paquete solo emite el conjunto antiguo X-RateLimit-*, así que un cliente que sigue el estándar moderno no ve nada y no puede saber que se está acercando al límite. Define standardHeaders: 'draft-6' y legacyHeaders: false.
2. El estado del límite de tasa está en el cuerpo del JSON. req.rateLimit en la respuesta funciona para este endpoint y en ningún otro lado. Un 429 devuelve el cuerpo del error, no este, así que la información desaparece justo cuando más se necesita. Los headers llegan en cada respuesta, incluyendo los rechazos.
3. app.use(limiter) aplica un solo presupuesto a todo. Un intento de login y una lectura de datos comparten el mismo 100, así que la navegación normal agota el presupuesto pensado para proteger el endpoint de login.
Conecta limitadores a cada ruta por separado, con números más estrictos en las sensibles.
Hay un cuarto problema que vale la pena notar si la API corre en más de una instancia: el almacén por defecto es interno al proceso, así que cada instancia cuenta por separado y el límite real es 100 veces la cantidad de instancias.
Hacia dónde va esto
El limitador funciona, y cada solicitud que cuenta se le atribuye a quien el paquete decide que la hizo. Hasta ahora, eso ha sido la dirección IP del cliente, elegida por defecto y no por ti.
Identificando clientes vuelve esa decisión explícita, porque contra qué cuenta un límite cambia a quién protege y a quién castiga.

