Un cliente atascado en un retry loop sin backoff puede meterle más carga a tu API en un minuto que tus otros diez mil clientes juntos. Desde tu lado no parece un ataque. Parece un bug de su parte, y se convierte en un incidente del tuyo. Un rate limiter frena a ese cliente antes de que llegue a ese punto.

¿Qué decide realmente un rate limiter?

En cada request, el limiter se hace una sola pregunta: ¿esta clave ha hecho demasiadas requests en la ventana actual? Tres decisiones convierten eso en algo que puedes implementar.

La clave es contra qué cuentas: una dirección IP, una API key, el ID de un usuario autenticado. Si eliges la equivocada, o bloqueas a usuarios inocentes que comparten una identidad, o dejas que el verdadero abusador se esconda entre ellos.

La ventana y el límite solo tienen sentido juntos. “100 requests” no es un límite; 100 por segundo y 100 por día son dos APIs muy distintas.

La acción es lo que pasa cuando la clave se pasa del límite. La mayoría de las APIs rechaza con 429 Too Many Requests. Un job en segundo plano o quien envía webhooks tiende más bien a encolar el trabajo y drenarlo después, que es el trabajo de una message queue (ver Qué Es una Cola de Mensajes y Cómo Usarla).

Qué algoritmo, qué store, qué headers: todo eso es implementación alrededor de esta única comprobación.

Cuatro algoritmos de rate limiting y lo que cada uno falla

Fixed window counter. Incrementa un contador para la clave y lo resetea cuando el reloj cruza un límite, digamos cada minuto en el minuto exacto. Cuesta un entero por clave. El defecto: un cliente que manda 100 requests a las 0:59 y otras 100 a la 1:00 ha enviado 200 requests en dos segundos sin romper “100 por minuto” en ninguna de las dos ventanas. En el límite, una fixed window deja pasar hasta el doble de la tasa declarada.

Sliding window log. Guarda el timestamp de cada request para la clave (un sorted set de Redis funciona bien), y en cada nueva request cuenta cuántas caen dentro de los últimos 60 segundos. Es exacto, así que el truco del límite no funciona. El costo es una memoria que crece con el tráfico: contar un millón de timestamps por clave no es lo mismo que leer un entero. Es la diferencia entre recorrer una lista completa y consultar un índice directamente, la misma que mide la Notación Big O Explicada con Ejemplos de Código.

Sliding window counter. Es el que la mayoría de las APIs debería usar. Mantén dos contadores de ventana fija, el actual y el anterior, y estima el conteo sliding como conteo_actual + conteo_anterior * (fracción de solapamiento). Si estás al 40% del minuto actual, cuentas todas las requests de la ventana actual más el 60% de la anterior. Dos enteros por clave, sin exploit de límite, y lo bastante cerca de lo exacto para límites reales.

Token bucket. Un bucket contiene hasta capacity tokens y se rellena a un rate fijo por segundo. Cada request consume un token; sin token, no hay request. Un cliente puede hacer un burst de hasta capacity requests de una vez, y después queda limitado al rate de recarga. Eso coincide con cómo llaman realmente a las APIs los navegadores y las apps: la carga de una página dispara ocho requests juntas, y luego nada por un rato. Un tope fijo por segundo rechazaría la mitad de esa carga de página.

Leaky bucket. Mismo bucket, objetivo opuesto. Las requests entrantes entran en una cola y salen a un rate constante, así que los burst se convierten en un flujo estable. El token bucket decide cuánto burst se le permite a un cliente. El leaky bucket decide cuán constante es el rate de salida. Úsalo cuando lo que está río abajo, una API de terceros con su propia quota o un worker pool de tamaño fijo, necesita un flujo constante.

Cómo construir un rate limiter con Redis

El contador tiene que vivir en un lugar que toda instancia de tu API pueda ver. Un contador en memoria del proceso funciona hasta que corres una segunda instancia; entonces cada una aplica el límite solo sobre la mitad del tráfico. Redis es el store habitual (ver Cómo Funciona el Caching con Redis y Cómo Usarlo si todavía no tienes uno configurado), y el motivo son sus comandos atómicos más que su velocidad.

Un fixed window counter en Python con INCR y EXPIRE:

1
2
3
4
5
def is_allowed(redis, key: str, limit: int, window_seconds: int) -> bool:
    count = redis.incr(f"rl:{key}")
    if count == 1:
        redis.expire(f"rl:{key}", window_seconds)
    return count <= limit

INCR es atómico en una única instancia de Redis, así que dos requests que llegan en el mismo microsegundo igual obtienen dos conteos distintos. El bug que arrastra la mayoría de los limiters hechos a mano es un GET seguido de un SET. Dos requests concurrentes leen el mismo conteo, las dos deciden que están por debajo del límite, y las dos pasan. Queda un hueco en la versión de arriba: si tu proceso muere entre INCR y EXPIRE, la clave nunca expira y ese cliente se queda bloqueado. Mover ambas llamadas a un script de Lua, como el token bucket de abajo, cierra ese hueco.

El token bucket necesita tres pasos: calcular el refill, tomar un token, escribir el estado de vuelta. Ningún comando de Redis hace las tres cosas solo, así que los ejecutas como un script de Lua con EVAL, que Redis ejecuta como una sola unidad atómica:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- KEYS[1] = bucket key, ARGV[1] = capacity, ARGV[2] = refill rate/sec, ARGV[3] = now
local bucket = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local last = tonumber(bucket[2]) or tonumber(ARGV[3])
local elapsed = tonumber(ARGV[3]) - last
tokens = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2]))
local ttl = math.ceil(tonumber(ARGV[1]) / tonumber(ARGV[2]))

if tokens < 1 then
  redis.call("HSET", KEYS[1], "tokens", tokens, "ts", ARGV[3])
  redis.call("EXPIRE", KEYS[1], ttl)
  return 0
end

redis.call("HSET", KEYS[1], "tokens", tokens - 1, "ts", ARGV[3])
redis.call("EXPIRE", KEYS[1], ttl)
return 1

Se llama con redis.eval(script, 1, f"bucket:{key}", capacity, refill_rate, time.time()). El script rellena el bucket según el tiempo transcurrido, toma un token en la misma pasada, y pone un TTL lo bastante largo para un refill completo, así un cliente que deja de llamar a la API no deja su bucket ocupando espacio en Redis para siempre. Ninguna otra request puede leer o cambiar el bucket mientras se ejecuta.

Qué headers enviar con una respuesta 429

Una request rechazada debería decirle al cliente cuándo volver a intentar.

Usa 429 Too Many Requests, no 403. El cliente no está prohibido, está adelantado.

Añade Retry-After: 30, en segundos o como fecha HTTP. Muchas bibliotecas cliente y SDKs HTTP lo leen para programar su reintento, y las que no lo hacen al menos le dan a una persona algo con qué trabajar.

Envía X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset en todas las respuestas, no solo en las rechazadas. Le dicen al cliente el techo, la quota que queda y cuándo se resetea la ventana. Es una convención de facto (la API de GitHub la usa) más que un estándar, pero es lo que buscan quienes desarrollan clientes. Un cliente bien hecho observa cómo baja Remaining y frena antes de llegar a un 429.

Existe una versión estándar en borrador: los campos RateLimit y RateLimit-Policy del draft-ietf-httpapi-ratelimit-headers del IETF. Lleva varios años como Internet-Draft y todavía no es un RFC. Envía los headers X-RateLimit-* ahora, y añade los campos estándar cuando el borrador se publique como RFC.

Errores comunes de rate limiting

Limitar solo por IP. Una red corporativa, el NAT de un operador móvil o un edge de CDN pueden poner a miles de usuarios reales detrás de una sola dirección. Usa como clave la API key o el ID de usuario autenticado siempre que tengas uno, y recurre a la IP solo para tráfico anónimo. JWT vs Sesión: Qué Autenticación Elegir explica cómo se establece esa identidad.

Un contador en memoria detrás de un load balancer. Cada instancia cuenta por su cuenta, así que un límite de 100 se convierte en 100 × el número de instancias. En cuanto escalas horizontalmente, el contador tiene que vivir en Redis, no en un dict local al proceso.

Un solo límite para toda la API. Una búsqueda y una exportación masiva no cuestan lo mismo de servir. Pon límites por ruta, o al menos por clase de costo.

Cero tolerancia a los burst. Un reintento de webhook, una importación masiva, una app móvil que se reconecta tras un tunnel: para un tope fijo por segundo, todo eso parece abuso. El token bucket existe justamente para este tráfico.

¿Qué rate limiter construir primero?

Para una API típica de lectura intensiva, construye un sliding window counter con clave en la API key, guardado en Redis, con los headers X-RateLimit-* en todas las respuestas. Evita el burst en el límite de la fixed window y el costo en memoria de un log completo de requests. Añade un token bucket solo en las rutas donde los clientes legítimamente envían lotes (entrega de webhooks, cargas masivas), y define su capacity según el tamaño del lote más grande que esperas, no según el rate en régimen estable.

Artículos relacionados