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

Throttling

Un encargado en la punta de un tobogán acuático. No rechaza a los niños, hace que cada uno espere un poco más que el anterior.

El throttling ralentiza las solicitudes después de que pasa cierta cantidad, en lugar de rechazarlas al llegar a un tope fijo. El cliente igual recibe una respuesta, solo que más tarde.

Una demora en lugar de un rechazo

express-slow-down es un middleware con la misma lógica que el limiter: cuenta las solicitudes dentro de una ventana de tiempo y empieza a demorarlas después de cierto umbral.

js
import { slowDown } from 'express-slow-down'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 2,
  delayMs: () => 5000,
})

app.get('/api/data', throttle, handler)

Tres parámetros: la ventana de tiempo, cuántas solicitudes pasan sin problema, y cuánto se demora todo lo que viene después.

Dispara cinco solicitudes al mismo tiempo y las dos primeras responden de inmediato. Las otras tres esperan cinco segundos cada una y luego se completan con éxito.

SolicitudLlegóDemoraRespondió
10sninguna0s
20sninguna0s
30s5s5s
40s5s5s
50s5s5s

Ninguna fue rechazada. Todas las solicitudes tuvieron éxito, y tres de ellas tardaron cinco segundos más de lo que hubieran querido.

JunoUna demora en lugar de un rechazo El cliente nunca se entera de que fue throttled. No hay ningún error ni código de estado que revise, solo una respuesta que tardó más.

Ahí está el atractivo y también la trampa. Nada se rompe, pero tampoco hay nada que le indique al cliente que baje el ritmo.

JunoUna demora en lugar de un rechazo Una solicitud demorada es una conexión que se mantiene abierta, así que el throttling te cuesta recursos justo donde rechazar los liberaría. Unos segundos por solicitud durante una ráfaga está bien; una demora larga aplicada de forma amplia es una manera de agotar tu propio pool de conexiones.

Ese es el argumento para combinarlo con un límite fijo en lugar de usarlo solo, y para mantener las demoras en pocos segundos.

JunoUna demora en lugar de un rechazo Verificado en express-slow-down 3.1.1: delayMs recibe una función, mientras que versiones anteriores aceptaban simplemente un número. Pasar un número ahora es un error de configuración en lugar de un valor por defecto silencioso, y ese es justo el tipo de cambio en una actualización que amerita fijar versiones.

La razón más profunda para preferir el throttling con clientes bien comportados es que aplica contrapresión sin necesitar que el cliente coopere. Un 429 solo ayuda si quien llama implementa un backoff; una demora lo ralentiza sin importar si escribió ese código o no.

Contra un atacante deliberado es más débil, porque puede abrir más conexiones y no le importa esperar. Usa el throttle para moldear el tráfico normal, y el rechazo para detener el abuso.

Qué tan rápido crece la demora

delayMs es una función de hits, la cantidad de solicitudes contadas en la ventana, así que la demora puede crecer con la presión.

js
delayMs: () => 5000                              // fija: siempre 5s
delayMs: (hits) => (hits - 2) * 1000             // incremental: 1s, 2s, 3s
delayMs: (hits) => hits * hits * 1000            // exponencial: 9s, 16s, 25s

Con delayAfter: 2, las tres formas se comportan muy distinto para la quinta solicitud:

HitFijaIncrementalExponencial
35s1s9s
45s2s16s
55s3s25s

La incremental resta delayAfter, así que la primera solicitud demorada espera un segundo en lugar de tres. La exponencial eleva al cuadrado el número de hit, así que sube tan rápido que la presión sostenida se vuelve dolorosa muy pronto.

JunoQué tan rápido crece la demora Mira la quinta solicitud en las tres columnas: tres segundos, o veinticinco. Mismo middleware, mismo umbral.

La curva que elijas importa más que el umbral, y eso solo se hace evidente cuando ves los números uno al lado del otro.

JunoQué tan rápido crece la demora La incremental es la opción por defecto más sensata. Es suave con un cliente que se pasó un poco y firme con uno que sigue insistiendo, y las demoras se mantienen en un rango que el timeout de quien llama va a tolerar.

La exponencial necesita un tope. En el hit 20, la versión al cuadrado pide 400 segundos, muy por encima de cualquier timeout de cliente, así que la solicitud queda abandonada mientras tu servidor sigue reteniendo la conexión por ella. maxDelayMs es lo que pone ese límite.

JunoQué tan rápido crece la demora Verificado en 3.1.1, y vale la pena saberlo antes de escribir una prueba: las solicitudes concurrentes no terminan en el orden en que llegaron. Cinco disparadas al mismo tiempo con una demora incremental devolvieron la solicitud 4 al segundo, y la solicitud 3 a los dos segundos, porque los números de hit se asignan en el orden en que el middleware las cuenta, no en el orden en que las enviaste.

Así que la posición de una solicitud demorada la decide el número de hit que le tocó, y cualquier prueba que dependa del orden de llegada va a fallar de forma intermitente por razones que no tienen nada que ver con tu código.

También vale la pena combinar el throttling con los encabezados RateLimit del limiter que va junto a él. Una demora sola no le da al cliente ninguna señal sobre la cual actuar, así que un cliente bien programado no tiene de dónde bajar el ritmo.

Qué cuenta la ventana

express-slow-down usa una ventana deslizante. Cada solicitud mira hacia atrás windowMs y cuenta los hits dentro de ese rango, y si no llegó nada en un período más largo que ese, el conteo vuelve a empezar de cero.

El detalle sutil es qué cuenta como estar dentro de la ventana: las solicitudes se cuentan según cuándo llegaron, no según cuándo se procesaron.

Con windowMs: 10000 y delayAfter: 2, cinco solicitudes que llegan a la vez y otras cinco a los 30 segundos:

  • Las primeras cinco llegan todas a los 0s. Dos pasan, tres se demoran y responden más tarde.
  • La segunda ráfaga llega a los 30s. Mirar hacia atrás diez segundos cubre de los 20s a los 30s, y no llegó nada en ese tramo, aunque las respuestas demoradas de la primera ráfaga todavía se estaban enviando.
  • Así que el conteo empieza de cero otra vez, dos pasan, y tres se demoran de nuevo.

Las respuestas demoradas que salen del servidor no extienden la ventana. Solo las nuevas llegadas lo hacen.

JunoQué cuenta la ventana El encargado del tobogán acuático piensa diez segundos hacia atrás y pregunta quién *llegó* en ese tiempo, no quién sigue parado esperando su turno.

Esos niños que están esperando fueron contados cuando llegaron. Contarlos dos veces sería cobrarles por la misma visita.

JunoQué cuenta la ventana Esto es lo que permite que los clientes con tráfico en ráfagas sigan funcionando. Gastan la ráfaga, esperan a que pase la ventana, gastan otra. Las demoras nunca se acumulan entre ráfagas, porque la ventana ya olvidó la anterior.

Si eso no es lo que quieres, la ventana necesita ser más larga que el intervalo entre ráfagas, o hace falta un límite fijo detrás del throttle.

JunoQué cuenta la ventana Contar por tiempo de llegada es la decisión correcta, pero sí genera una interacción curiosa con un limiter que va después. Una solicitud demorada dieciséis segundos es contada por el throttle al llegar y por el limiter al procesarse, así que los dos middlewares están midiendo la misma solicitud en momentos distintos.

Eso produce el reordenamiento que se ve en la siguiente sección, donde una solicitud posterior adelanta a una anterior porque esta última todavía está esperando su temporizador.

Vale la pena recordarlo al leer los logs: los conteos del throttle y los del limiter no van a coincidir, y ninguno de los dos está equivocado.

Combinar un throttle y un limiter

Los dos se pueden combinar, y el orden importa porque Express ejecuta los middlewares de izquierda a derecha:

js
app.get('/api/data', throttle, limiter, handler)

Poner el throttle primero significa que las solicitudes se ralentizan progresivamente, y solo pasado un tope fijo el limiter las rechaza. Invierte el orden y el limiter corta a los clientes antes de que el throttle llegue siquiera a ser suave con ellos.

Un caso práctico del curso. El sistema se degrada después de tres solicitudes en diez segundos, así que la cuarta debe demorarse 1s, la quinta 4s, la sexta 9s. Y no más de siete solicitudes en treinta segundos, en total.

js
import { slowDown } from 'express-slow-down'
import { rateLimit } from 'express-rate-limit'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 3,
  delayMs: (hits) => (hits - 3) * (hits - 3) * 1000,
})

const limiter = rateLimit({
  limit: 7,
  windowMs: 30000,
  message: 'Too many requests',
  standardHeaders: 'draft-6',
  legacyHeaders: false,
})

La fórmula de la demora se construye hacia atrás a partir del patrón requerido. El hit 4 debe dar 1, el hit 5 debe dar 4, el hit 6 debe dar 9, que son 1², 2², 3². Restar delayAfter convierte el número de hit en la base, y luego elevarlo al cuadrado genera la curva.

Ejecuta siete solicitudes rápidas seguidas y pasa algo inesperado. La solicitud 7, demorada dieciséis segundos, llega al limiter después que la solicitud 8, que tuvo un temporizador más corto. La solicitud 8 se convierte en el séptimo éxito y la solicitud 7 es rechazada.

Las solicitudes con throttle no forman una cola. Cada una tiene su propio temporizador y llega cuando llega.

JunoCombinar un throttle y un limiter El reordenamiento parece un error, pero no lo es. Cada solicitud demorada recibió su propio temporizador al llegar, así que una solicitud con un temporizador corto termina antes que una con uno largo, sin importar en qué orden llegaron.

Un leaky bucket las hubiera mantenido en orden. Un throttle reparte temporizadores y los deja correr.

JunoCombinar un throttle y un limiter Lo cual significa que un throttle combinado con un limiter puede rechazar una solicitud que un limiter solo habría aceptado. La solicitud 7 estaba dentro del límite cuando llegó y fuera de él para cuando terminó su demora.

Vale la pena saberlo antes de tratarlo como una falla del limiter. El limiter contó correctamente; lo que cambió fue el momento en que el throttle dejó que la solicitud se presentara para ser contada.

JunoCombinar un throttle y un limiter Ninguno de estos middlewares identifica al cliente, así que todo este tráfico comparte un solo presupuesto. Agregar keyGenerator a ambos es lo que convierte esto en un control por cliente, donde un usuario intensivo se ralentiza y todos los demás son atendidos de inmediato.

Sin eso, la ráfaga de un solo cliente ralentiza a todos, lo cual convierte un control protector en una denegación de servicio autoinfligida.

El esquema que aguanta en producción tiene tres capas: identificar al cliente, aplicar throttle para moldear su tráfico, y rechazar al pasar un tope fijo. Cada capa responde una pregunta distinta, y los resultados se vuelven lo bastante complicados como para que valga la pena dibujar un gráfico de tiempo de llegada, demora y resultado antes de confiar en tu configuración.

Ponlo a prueba

Un endpoint está configurado con windowMs: 10000, delayAfter: 2, delayMs: (hits) => hits * hits * 1000, y sin ningún rate limiter.

  1. Llegan cuatro solicitudes a la vez. ¿Cuándo responde cada una?
  2. Un cliente envía cuatro solicitudes cada 15 segundos, indefinidamente. ¿Las demoras empeoran con el tiempo?
  3. ¿Qué pasa si llega una quinta solicitud y su demora supera el timeout del cliente?
  4. ¿Cómo cambiarías la configuración para que un solo cliente no pueda ralentizar a todos los demás?
Compara tus respuestas
#Respuesta
1Las solicitudes 1 y 2 responden de inmediato. La solicitud 3 es el hit 3, así que 3² = 9 segundos. La solicitud 4 es el hit 4, así que 4² = 16 segundos
2No. La ventana es de 10 segundos y el intervalo es de 15, así que cada ráfaga mira hacia atrás a una ventana vacía y empieza a contar desde cero. El mismo patrón de dos gratis, luego 9s, luego 16s se repite indefinidamente
3El cliente se rinde y tu servidor sigue reteniendo la conexión hasta que expira la demora. El trabajo se hace y nadie lo recibe, por eso las demoras exponenciales necesitan maxDelayMs
4Agrega un keyGenerator para que el throttle cuente por cliente en lugar de globalmente. Sin eso, cada solicitud comparte un único presupuesto y un cliente intensivo ralentiza a todos

La pregunta 2 es la que sorprende a la gente. Una demora exponencial suena como si castigara el abuso sostenido, pero un cliente que espacia sus ráfagas para caer fuera de la ventana nunca la siente subir en absoluto. La curva solo muerde dentro de una misma ventana.

Hacia dónde va esto

Cinco algoritmos y dos controles, todos respondiendo una sola pregunta: cuánto se le permite pedir a este cliente, y qué pasa cuando pide más.

El razonamiento se extiende más allá de tus propios servidores. Cada API externa que consumes tiene sus propios límites, así que entender esto también mejora tu código del lado del cliente: cómo baja el ritmo y cómo maneja un 429 en lugar de seguir insistiendo contra un servicio que ya está sufriendo.

Con esto se cierra el manual. Cinco secciones, desde pensar como un atacante hasta la seguridad de entradas, la identidad y la prevención de abuso, cada una construida sobre un ejemplo funcional que puedes romper y luego arreglar.