Skip to content
This page has been auto-translated and may contain errors.View in English

Construyendo un limitador de tasa

Un endpoint, y un script que le pega quince veces seguidas.

js
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.

js
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:

js
req.rateLimit  // { limit, remaining, reset, used }

Es útil para depurar, y es el lugar equivocado para que un cliente lea esta información.

JunoPoniendo un limitador al frente La posición del middleware es todo el diseño, y es el mismo esquema que en el capítulo de validación: algo se pone al frente, y el handler solo se ejecuta si eso pasó primero.

No cambias el handler en absoluto. Cambias lo que tiene que pasar antes.

JunoPoniendo un limitador al frente Vale la pena conectar el limitador a cada ruta por separado, en lugar de a toda la app. Un endpoint de login y uno de solo lectura necesitan presupuestos muy distintos, y un único limitador global les da el mismo a los dos.

Lo más común es tener un limitador general y generoso para toda la app, más limitadores específicos y estrictos en los pocos endpoints que los necesitan.

JunoPoniendo un limitador al frente El almacén por defecto es memoria dentro del propio proceso, lo que significa que cada instancia cuenta por separado y tu límite real se multiplica por la cantidad de instancias que corras. Dos instancias detrás de un balanceador de carga convierten un límite de 5 en 10.

Arreglarlo requiere un almacén compartido, y la versión ingenua con Redis tiene una condición de carrera. Un contador de leer-decidir-escribir, probado con 400 solicitudes concurrentes contra un límite de 100, dejó pasar las 400.

La solución es hacer que la verificación sea atómica, con un incremento atómico o un script de Lua, ya que Redis ejecuta un script hasta el final sin interrupciones.

También vale la pena conocer passOnStoreError, que decide qué pasa cuando el almacén no está disponible. Por defecto es false, así que el tráfico se bloquea y una caída de Redis se convierte en una caída para ti.

Si conviene eso o dejar todo abierto por defecto depende enteramente de qué hace el endpoint.

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:

ÉpocaHeaders
AntiguoX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
Estándar, desde alrededor de 2021RateLimit-Limit, RateLimit-Policy, RateLimit-Remaining, RateLimit-Reset

El paquete todavía usa el conjunto antiguo por defecto, así que pide el moderno explícitamente:

js
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:

http
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60

Con esto en su lugar, req.rateLimit desaparece del cuerpo del JSON. Los headers hacen el trabajo como corresponde.

JunoDiciéndole al cliente cuál es su situación Los headers llegan en cada respuesta, no solo en los rechazos. Eso es lo que los hace útiles.

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.

JunoDiciéndole al cliente cuál es su situación Presta atención a las unidades de Reset, porque los dos formatos no coinciden. Verificado en la versión 8.7.0: el antiguo X-RateLimit-Reset es un timestamp Unix, mientras que el RateLimit-Reset del draft-6 son segundos restantes.

Leer uno como si fuera el otro te da una espera de unos cincuenta años o un reintento que se dispara de inmediato, y ambos casos parecen un bug del cliente en lugar de un problema de unidades.

JunoDiciéndole al cliente cuál es su situación El estándar avanzó desde el draft-6 y el paquete lo sigue de cerca. Los drafts 7 y 8 reemplazan los campos separados por un único header combinado, y la versión 8 acepta 'draft-6', 'draft-7' o 'draft-8' donde las versiones anteriores tomaban un booleano. Pasar true todavía funciona y significa draft-6.

El draft-8 no se parece en nada a los demás, verificado en 8.7.0:

RateLimit: "3-in-10sec"; r=2; t=10

Política con nombre, restante, y tiempo para reiniciar, todo en un solo campo estructurado. Vale la pena conocerlo antes de escribir un cliente que analice estos valores, y vale la pena fijar el draft explícitamente para que un futuro cambio de valor por defecto no te lo cambie sin avisar.

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:

SolicitudesResultado
1 a 3200, con Remaining contando 2, 1, 0
4 y 5429, Reset con la cuenta regresiva
6 a 8200 de nuevo, la ventana se reinició
9 y 10429

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.

JunoViendo cómo se reinicia Ralentizar el cliente de prueba es el truco que vale la pena recordar. Disparadas todas de golpe, todo pasa dentro de una sola ventana y el limitador parece un simple interruptor de encendido y apagado.

Con dos segundos entre solicitudes puedes ver el reinicio en acción, y esa es la parte que realmente explica el comportamiento.

JunoViendo cómo se reinicia Prueba con el cliente real, no recargando el navegador. Un navegador manda sus propias solicitudes extra para íconos y otros recursos, cada una consumiendo presupuesto que no pensabas gastar, lo que hace que los números confundan antes de que hayas entendido nada.

Un pequeño script que dispare una cantidad conocida de solicitudes a un intervalo conocido es la herramienta que hace legible el comportamiento del limitador.

JunoViendo cómo se reinicia Límites estrictos como 3 cada 10 segundos sirven para ver el comportamiento, nunca para producción. Los números reales salen de medir qué hacen los clientes legítimos, y luego dejar margen por encima del más exigente de ellos.

Si puedes, lanza primero un limitador en modo de monitoreo: cuenta y registra lo que se habría rechazado, sin rechazarlo de verdad.

Una semana de eso te dice si tu número protege de verdad o si es una caída que te estás programando tú mismo. Sale mucho más barato que enterarte por una cola de soporte.

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.

js
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.