La mayoría de los rate limiters en producción no están mal porque alguien eligió el algoritmo incorrecto — están mal porque alguien eligió un algoritmo que solo funciona correctamente en un solo proceso, y luego lo deployó en cuatro réplicas detrás de un load balancer. Cada réplica felizmente cuenta requests contra su propio diccionario en memoria, un cliente que martilla la API se enruta round-robin entre las cuatro, y el “límite” que se suponía que lo tapeaba a 100 requests por minuto silenciosamente se convierte en 400. Nadie lo nota hasta que llega la factura de una API downstream, o un patrón de abuso tiene cuatro veces más margen del que el límite fue diseñado para permitir.
La elección del algoritmo sí importa — fixed window, sliding window, token bucket y leaky bucket se comportan genuinamente diferente en las condiciones de borde que hacen difícil el rate limiting, como el tráfico legítimo bursty y el borde de una ventana de tiempo. Pero el algoritmo es solo la mitad del problema. La otra mitad es la coordinación: cómo hacer que una operación de increment-and-check sea atómica cuando tiene que ser correcta en cada réplica de tu API al mismo tiempo, sin la condición de carrera check-then-increment que silenciosamente duplica tu límite efectivo bajo carga concurrente.
Aprenderás:
- La diferencia de comportamiento real entre fixed window, sliding window log, sliding window counter, token bucket y leaky bucket
- Por qué los contadores de fixed window dejan pasar hasta 2x su límite declarado justo en los límites de la ventana
- Cómo el token bucket permite bursts controlados mientras el sliding window impone una tasa más suave y estricta
- Por qué un rate limiter en memoria silenciosamente se rompe en el momento en que corres más de una réplica de API
- Cómo hacer un rate limiter respaldado por Redis atómico entre réplicas usando un script Lua, evitando la carrera check-then-increment
- Una implementación funcional de token bucket en Python que puedes usar como middleware de FastAPI
Tabla de contenidos
- Lo básico
- La arquitectura completa
- Explicación de las capas principales
- Walkthrough de extremo a extremo
- Casos especiales
- Desafíos de escala y producción
- Ejemplos de código
- Errores comunes
- Buenas prácticas para producción
Lo básico
Qué decide realmente un rate limiter
En su núcleo, cada algoritmo de rate limiting responde una pregunta para cada request entrante: dado cuántos requests ya ha hecho este cliente recientemente, ¿debería pasar este, o rechazarse con un 429 Too Many Requests? Cada algoritmo en este artículo responde esa pregunta diferente — algunos rastrean un conteo fijo en un slot de tiempo fijo, algunos rastrean una dotación que se drena continuamente, algunos rastrean un promedio ponderado suavemente entre dos ventanas adyacentes — pero la interfaz que cada uno expone a tu aplicación es la misma: permitir o denegar, y usualmente cuánto tiempo hasta que el cliente debería reintentar.
Por qué el algoritmo que eliges realmente importa
- El comportamiento en el borde difiere drásticamente. Un cliente que está bien dentro de un límite declarado bajo un algoritmo puede superar bien ese límite con otro, puramente por cómo cada algoritmo trata el borde de una ventana de tiempo.
- La tolerancia al burst es una decisión de producto, no solo técnica. Algunas APIs quieren permitir que un cliente use la cuota completa de un minuto en un burst rápido y luego esté en silencio; otras quieren el tráfico distribuido uniformemente. Esa es una elección de algoritmo, no un ajuste de configuración.
- Hacerlo mal es invisible hasta que no lo es. Un rate limiter que solo “casi siempre” funciona parece idéntico a uno correcto bajo tráfico normal — la brecha solo aparece bajo carga concurrente o justo en los umbrales de abuso, que es exactamente cuándo necesitas que realmente funcione.
La arquitectura completa
client request
│
▼
┌─────────────────────┐
│ rate limit middleware │──── ask: allowed?
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Redis (shared state) │ ← atomic INCR / Lua script
│ key: user:123:bucket │ so every API replica
└─────────────────────┘ agrees on the same count
│
allow ──┴── deny (429)
El principio guía: el algoritmo define la matemática de cuándo permitir un request, pero la capa de almacenamiento define si esa matemática es realmente correcta una vez que más de un proceso la está evaluando de forma concurrente. Hazlo mal en la capa de almacenamiento y el mejor algoritmo del mundo igual se filtra.
Explicación de las capas principales
1. Fixed Window Counter
Qué es: Divide el tiempo en slots fijos (por ejemplo, cada 00:00–00:59 de cada minuto) y cuenta los requests contra el slot en el que cae la hora actual. Cuando el conteo del slot supera el límite, rechaza; cuando el reloj cambia a un nuevo slot, el contador se resetea a cero.
Por qué importa: Es el algoritmo más simple de implementar — un solo contador con un TTL — y por eso sigue siendo el más común en producción. Pero tiene un fallo de borde conocido: un cliente puede enviar su límite completo en el último segundo de una ventana, y luego enviar su límite completo en el primer segundo de la siguiente, obteniendo efectivamente el doble de la tasa pretendida en el tramo de dos segundos que abarca el límite.
Consejo de producción: Fixed window está bien para límites gruesos y generosos (10,000 requests/hora en un free tier) donde un burst 2x en el borde no es materialmente dañino. Es la elección incorrecta para límites estrechos donde ese burst importa, como un limitador de intentos de login.
2. Sliding Window Log
Qué es: Almacena un timestamp para cada request que hace un cliente, y en cada nuevo request cuenta cuántos timestamps almacenados caen dentro de la ventana trasera (por ejemplo, los últimos 60 segundos desde ahora, no un límite fijo de reloj), descartando lo más antiguo.
Por qué importa: Este es el algoritmo más preciso — sin fallo de borde en absoluto, porque la ventana siempre es relativa al instante actual. El costo es el almacenamiento: una lista creciente de timestamps por cliente en lugar de un solo entero, lo que es más pesado a escala.
Consejo de producción: Los sorted sets de Redis (ZADD, ZREMRANGEBYSCORE, ZCARD) implementan esto limpiamente — puntúa cada entrada por su timestamp, recorta todo lo que está fuera de la ventana y cuenta lo que queda. Es preciso, pero presupuesta la memoria extra por cliente rastreado en comparación con un solo contador.
3. Sliding Window Counter
Qué es: Un compromiso práctico: mantén dos contadores de fixed window (la ventana actual y la anterior), y estima el conteo deslizante como una mezcla ponderada de ambos, basada en qué tan adentro de la ventana actual estás.
Por qué importa: Esto te da la mayor parte de la precisión de un log deslizante completo — el fallo de burst en el borde se reduce drásticamente, no se elimina completamente — mientras mantiene el costo de almacenamiento en dos enteros por cliente en lugar de una lista creciente de timestamps. Es el algoritmo que la mayoría de las bibliotecas de rate limiting (incluyendo las implementaciones por defecto de Cloudflare y Kong) realmente incluyen, porque es la mejor relación precisión-costo para la mayoría de las APIs.
Consejo de producción: Este es un default razonable cuando no tienes una razón específica para usar token bucket o un log deslizante completo — es barato, suficientemente preciso y fácil de razonar.
4. Token Bucket
Qué es: Cada cliente tiene un cubo que contiene hasta un número máximo de tokens. Los tokens se recargan a una tasa constante (digamos, uno cada 100ms para un límite de 10 req/s), y cada request consume uno. Un cubo vacío rechaza el request; uno lleno deja que un cliente gaste todo el cubo en un burst rápido.
Por qué importa: Token bucket es el único algoritmo en esta lista que deliberadamente permite bursting controlado — un cliente que ha estado inactivo puede disparar inmediatamente la cantidad completa de un cubo lleno, luego tiene que esperar a que los tokens se recarguen. Eso es una funcionalidad, no un bug, para APIs donde los clientes legítimos genuinamente hacen bursts (una app móvil sincronizando después de estar offline, un dashboard cargando varios widgets a la vez) pero igual necesitas un techo fijo en la tasa sostenida.
Consejo de producción: Ajusta la capacidad del cubo y la tasa de recarga como dos perillas separadas — la capacidad controla cuánto burst toleras, la tasa de recarga controla la tasa sostenida a largo plazo. Colapsarlos en un solo número “requests por minuto” pierde esa flexibilidad.
5. Leaky Bucket
Qué es: El inverso del token bucket — los requests entran en una cola (el “cubo”) y se procesan (“se filtran”) a una tasa estrictamente constante independientemente de qué tan bursty fueron las llegadas. Un cubo lleno descarta nuevos requests.
Por qué importa: El leaky bucket impone una tasa de salida genuinamente suave, lo que importa cuando tú eres el que llama a una API downstream con rate limiting y necesitas garantizar que nunca excedes su límite, en lugar de solo limitar tu propio tráfico entrante. Es menos común para el rate limiting en el borde de API porque agrega queuing y latencia de requests en lugar de un rechazo rápido.
Consejo de producción: Usa leaky bucket específicamente cuando estás dando forma al tráfico saliente hacia una API de terceros con un límite estricto, no cuando proteges el borde de entrada de tu propia API — token bucket o sliding window counter se adaptan mejor a ese caso.
Walkthrough de extremo a extremo
Traza un burst de requests a través de un rate limiter de token bucket respaldado por Redis, a través de dos réplicas de API:
- El cliente envía 5 requests en rápida sucesión con un límite de “10 requests, recargando a 1/segundo.” Ambos requests podrían aterrizar en cualquiera de las dos réplicas de API detrás de un load balancer.
- La réplica A recibe el request 1. Ejecuta un script Lua contra Redis: lee el conteo actual de tokens, calcula los tokens agregados desde el último timestamp de recarga, resta uno por este request, escribe de vuelta el nuevo conteo y timestamp — todo dentro de una operación atómica de Redis.
- La réplica B recibe el request 2 unos pocos milisegundos después, antes de que la escritura de la réplica A sea incluso relevante para ella, porque ambas réplicas hablan a la misma clave de Redis, no a su propia memoria local. La ejecución del script Lua de la réplica B está en cola y es atómica relativa a la de la réplica A — Redis ejecuta en un solo hilo los comandos (y scripts), así que no hay carrera entre las dos.
- Los requests 3–5 continúan de la misma manera, cada uno decrementando el mismo contador compartido independientemente de qué réplica los maneje.
- El cliente envía el request 11 inmediatamente después. El cubo está vacío (10 tokens consumidos, la tasa de recarga es solo 1/segundo, y ha pasado menos de un segundo) — el script Lua devuelve “deny”, y la API responde
429con un headerRetry-Aftercalculado a partir de la tasa de recarga. - Un segundo después, llega un nuevo request. El script Lua calcula el tiempo transcurrido desde la última escritura, agrega un token recargado y permite el request.
Casos especiales
Limitación por usuario vs. por IP. Los endpoints autenticados deberían usar la ID de usuario como clave del limitador, no la IP — NAT y proxies corporativos ponen muchos usuarios legítimos detrás de una IP, y un límite de IP compartida penaliza a todos ellos por el tráfico de un usuario. Los endpoints no autenticados (login, registro, restablecimiento de contraseña) caen de vuelta a limitación basada en IP, que es también por qué la protección de login por fuerza bruta necesita su propio limitador más estricto, separado del límite general de API.
Rate limiting distribuido entre réplicas. Esta es la razón principal para usar Redis (u otro almacenamiento compartido) en lugar de contadores en memoria: cada réplica necesita observar el mismo conteo, o el límite efectivo escala con tu conteo de réplicas en lugar de mantenerse fijo. Un limitador basado en dict en memoria está bien para un servidor de desarrollo de proceso único y activamente equivocado para cualquier cosa escalada horizontalmente.
Tolerancia al burst para clientes legítimamente irregulares. Una app móvil que agrupa requests después de reconectarse, o un dashboard que dispara varias llamadas paralelas al cargar la página, necesita algo de tolerancia al burst o se ve limitado durante el uso completamente legítimo. Las perillas separadas de capacidad/tasa de recarga del token bucket son el ajuste natural aquí; una ventana deslizante estricta no lo es.
Rate limiting dentro del manejo de requests en sí. Si tu API acepta un request y luego pasa el trabajo más pesado a un worker en segundo plano — construir un reporte, enviar un lote de emails — el límite usualmente pertenece al endpoint de entrada, no al trabajo en segundo plano, ya que ese es el punto donde realmente controlas la admisión.
Desafíos de escala y producción
La condición de carrera check-then-increment. El bug más común en un rate limiter casero: leer el conteo actual, verificarlo contra el límite en el código de la aplicación, y solo entonces emitir un incremento separado — como dos round trips a Redis en lugar de una operación atómica. Bajo requests concurrentes, dos requests pueden leer un conteo de 9 (bajo un límite de 10), ambos decidir permitir, y ambos incrementar — dejando pasar 11 en lugar de 10. El arreglo es hacer la lectura, el cálculo y la escritura dentro de un solo script Lua (o transacción MULTI/EXEC, o extensiones de comandos OBJECT de Redis 7), de modo que el modelo de ejecución de un solo hilo de Redis haga toda la operación atómica.
Clock drift entre réplicas. Los algoritmos que dependen de timestamps de reloj de pared (la recarga del token bucket, el límite de recorte del sliding window) deberían calcular el tiempo transcurrido usando el propio comando TIME de Redis dentro del script Lua, no el reloj local de cada réplica — unos pocos cientos de milisegundos de drift son suficientes para hacer que la matemática de recarga sea sutilmente incorrecta si cada réplica confía en la suya.
Redis como punto único de fallo. Un limitador que falla cerrado cuando Redis no está disponible convierte un blip de Redis en una interrupción completa de la API. La mayoría de los setups en producción fallan abiertos en su lugar — si Redis está caído, dejar pasar los requests en lugar de rechazar todo — ya que el trabajo de un limitador es prevenir el abuso, no convertirse en un segundo punto de fallo. Registra el fallo en voz alta de todas formas.
Expiración de claves y crecimiento de memoria. Cada clave de cliente necesita un TTL ligeramente más largo que su ventana, o Redis acumula un contador para cada cliente que alguna vez hizo un request y nunca lo expira. Configúralo explícitamente (EXPIRE después de la escritura del script Lua) y vuelve a verificarlo cada vez que cambies la estructura de datos de la clave — es fácil olvidarlo silenciosamente en un refactor posterior.
Ejemplos de código
Un token bucket respaldado por Redis implementado como script Lua atómico, llamado desde Python:
import time
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
# KEYS[1] = bucket key
# ARGV[1] = capacity (max tokens)
# ARGV[2] = refill_rate (tokens per second)
# ARGV[3] = now (unix timestamp, float)
# ARGV[4] = requested tokens (usually 1)
TOKEN_BUCKET_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, math.ceil(capacity / refill_rate) + 1)
return {allowed, tokens}
"""
_script = r.register_script(TOKEN_BUCKET_SCRIPT)
def is_allowed(client_id: str, capacity: int = 10, refill_rate: float = 1.0) -> tuple[bool, float]:
now = time.time()
allowed, remaining = _script(
keys=[f"ratelimit:{client_id}"],
args=[capacity, refill_rate, now, 1],
)
return bool(allowed), float(remaining)
Conectado como middleware de FastAPI:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_id = request.headers.get("x-api-key", request.client.host)
allowed, remaining = is_allowed(client_id, capacity=10, refill_rate=1.0)
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "rate limit exceeded"},
headers={"Retry-After": "1"},
)
response = await call_next(request)
response.headers["X-RateLimit-Remaining"] = str(int(remaining))
return response
Una verificación rápida de concurrencia:
seq 1 20 | xargs -P 20 -I {} curl -s -o /dev/null -w "%{http_code}\n" \
-H "x-api-key: test-client" http://localhost:8000/ping
Con capacidad 10, espera exactamente 10 200s y 10 429s, independientemente de cuántas réplicas estén detrás del load balancer — el script Lua, no la memoria de ninguna réplica individual, es la fuente de verdad.
Errores comunes
Error: rate limiting con un contador en memoria detrás de un deployment multi-réplica. Cada réplica rastrea su propio conteo, así que el límite efectivo se multiplica por el conteo de réplicas. Solución: respalda el limitador con Redis (u otro almacenamiento compartido) en el momento en que corras más de un proceso.
Error: llamadas separadas de read-then-write en lugar de una operación atómica. Esta es la carrera check-then-increment descrita arriba. Solución: haz toda la lectura/cálculo/escritura dentro de un solo script Lua o transacción de Redis.
Error: usar fixed window para un límite estrecho y sensible a la seguridad. La limitación de intentos de login con fixed window le permite a un atacante duplicar sus intentos efectivos cronometrando requests alrededor del límite de la ventana. Solución: usa sliding window o token bucket para cualquier cosa donde el comportamiento de burst en el límite sea realmente explotable.
Error: olvidar un TTL en las claves de rate limit. La memoria de Redis crece sin límite a medida que cada cliente que alguna vez hizo un request obtiene una clave permanente. Solución: siempre establece una expiración ligeramente más larga que la ventana o el período de recarga.
Error: fallar cerrado cuando Redis no está disponible. Una interrupción de Redis se convierte en una interrupción completa de la API en lugar de una brecha de rate limiting. Solución: falla abierto por defecto para rate limiting, con logging/alerting en voz alta sobre el fallo en sí.
Buenas prácticas para producción
- Elige sliding window counter como tu default, y usa token bucket específicamente cuando quieras recompensar a los clientes inactivos con capacidad de burst.
- Siempre implementa el check-and-decrement como una operación atómica de Redis, mediante un script Lua — nunca dos round trips.
- Usa la ID de usuario autenticado como clave donde sea posible, cayendo de vuelta a IP solo para endpoints no autenticados.
- Establece un TTL en cada clave de rate limit y calcúlalo a partir de los parámetros de ventana/recarga, no como una estimación fija.
- Decide explícitamente tu comportamiento cuando Redis está caído — falla abierto para límites generales de API, y trata esa decisión como un tradeoff consciente, no un default que nunca examinaste.
Conclusión
El algoritmo que eliges cambia cómo se comporta tu rate limiter en los bordes — cómo maneja un burst, un límite de ventana, un cliente inactivo-luego-activo — y elegir uno que no coincide con tu patrón de tráfico real es un error de diseño real y corregible. Pero el incidente de producción más común no es un desajuste de algoritmo en absoluto; es un limitador que solo fue correcto en un proceso, silenciosamente multiplicando su límite efectivo en el momento en que una segunda réplica se unió a la flota. Haz bien la atomicidad primero, con una sola operación de Redis haciendo el read-check-write como un paso indivisible, y la elección del algoritmo se convierte en una decisión de ajuste en lugar de una de corrección.
Si tu rate limiter actual es un diccionario en memoria y estás corriendo más de una réplica, esa es la brecha que vale la pena cerrar antes que cualquier otra cosa en este artículo.

