Diseña un rate limiter como servicio independiente que múltiples APIs puedan invocar
El rate limiting implementado por separado en cada servicio se desincroniza rápido — un limitador compartido, atómico y respaldado por Redis es el patrón que realmente sostiene una cuota en toda una plataforma.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Diseña un rate limiter como servicio independiente que múltiples APIs puedan invocar
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué sacar el rate limiting de cada API
Toda API necesita alguna forma de rate limiting — para protegerse a sí misma del abuso, para hacer cumplir la cuota de un plan de precios, o para proteger una dependencia downstream de saturarse. El enfoque ingenuo es implementarlo dentro de cada servicio, pero eso significa que cada equipo reinventa la misma lógica, las políticas se desincronizan entre servicios, y un cliente que golpea tres APIs distintas de tres equipos distintos no puede limitarse consistentemente contra una sola cuota a menos que esos equipos de alguna forma compartan estado. Sacar el rate limiting a un servicio independiente — con un almacén compartido detrás — te da un solo lugar para definir la política, un solo lugar para cambiarla, y una única fuente de verdad sobre "cuánto de su cuota ha usado este cliente ahora mismo", sin importar qué API esté llamando.
#Los algoritmos, y qué te da realmente cada uno
Contador de ventana fija. Cuenta peticiones en ventanas discretas (por ejemplo, un contador que se reinicia cada 60 segundos). Trivial de implementar y barato de almacenar — un entero y una expiración por llave — pero permite que un cliente haga una ráfaga hasta el doble del límite pretendido justo en el límite de una ventana: 100 peticiones en el último segundo de una ventana, y otras 100 en el primer segundo de la siguiente, y ambas ventanas por separado parecen cumplir el límite.
Log de ventana deslizante. Guarda la marca de tiempo de cada petición en una estructura ordenada, y cuenta cuántas caen dentro de la ventana móvil en cada verificación. Esto es exactamente preciso — sin el problema de ráfaga en el límite — pero el costo de memoria escala con el volumen de peticiones por llave, lo cual se vuelve caro a tasas altas.
Contador de ventana deslizante. Un punto medio práctico: mantén un contador para la ventana fija actual y otro para la anterior, y estima el conteo de la ventana deslizante como una mezcla ponderada de ambos según qué tan avanzada esté la ventana actual. Es una aproximación, pero buena, y cuesta el mismo almacenamiento mínimo que el enfoque de ventana fija.
Token bucket (cubo de fichas). Un cubo contiene hasta `capacity` fichas y se rellena a razón de `refill_rate` fichas por segundo; cada petición consume una ficha (o más, para costos ponderados) y se rechaza si el cubo está vacío. Este es el algoritmo más usado en rate limiters de producción porque permite naturalmente ráfagas cortas hasta la capacidad del cubo mientras sigue imponiendo una tasa promedio estricta a lo largo del tiempo — lo cual coincide mucho mejor con cómo se comporta el tráfico real de clientes que un tope duro por segundo.
Leaky bucket (cubo con fuga). Conceptualmente el inverso del token bucket: las peticiones llenan una cola (el cubo), y se procesan ("gotean") a una tasa constante sin importar qué tan ráfaga haya sido la entrada. Esto tiene menos que ver con rechazar el exceso de tráfico y más con moldearlo en una tasa de salida constante — útil cuando la preocupación es proteger un sistema downstream de una entrada ráfaga en vez de imponer estrictamente una cuota por cliente.
Un primo cercano y más eficiente en almacenamiento del token bucket que vale la pena conocer por nombre es GCRA (Generic Cell Rate Algorithm): en vez de guardar tanto un conteo de fichas como una marca de tiempo por llave, guarda un único valor de "tiempo teórico de llegada" y deriva la decisión aritméticamente, algo que varios rate limiters de alto throughput usan específicamente para reducir a la mitad el almacenamiento por llave.
#Arquitectura de un servicio de rate limiting independiente
Hay dos formas comunes de conectarlo. La primera es una librería cliente embebida en cada servicio que habla directamente con el clúster de Redis compartido — la latencia más baja, ya que no hay un salto de red extra a través de un servicio intermediario, pero ahora cada servicio necesita hablar el protocolo de Redis y coincidir exactamente en el formato de las llaves y la lógica del script, lo cual es una forma de acoplamiento. La segunda es un microservicio dedicado de rate limiting (comúnmente expuesto vía gRPC por su bajo overhead) al que cada otro servicio llama antes de continuar — este es el patrón que usan los API gateways y los service meshes: el gateway llama al servicio de rate limiting en cada petición, recibe una decisión de permitir/denegar, y solo reenvía la petición downstream si fue permitida. Cuesta un salto de red extra por petición, pero significa que la lógica de política, el almacén de datos y cualquier cambio futuro de algoritmo viven en exactamente un lugar.
Cualquiera que sea la forma que elijas, el almacén detrás necesita ser rápido (en memoria), compartido (cada instancia del rate limiter debe ver los mismos conteos, o en realidad no estás imponiendo un límite global), y soportar lectura-modificación-escritura atómica — que es exactamente para lo que está construido Redis, accedido a través de un script de Lua.
#Implementando token bucket de forma atómica en Redis
La versión ingenua de esto — hacer `GET` del conteo actual de fichas, calcular el nuevo valor en el código de la aplicación, y hacer `SET` de vuelta — tiene una condición de carrera: dos peticiones concurrentes pueden leer el mismo conteo de fichas antes de que ninguna escriba de vuelta, y ambas terminan permitidas cuando solo una debería haberlo sido. La solución es hacer toda la verificación-y-actualización como una única operación atómica dentro del propio Redis, usando un script de Lua (Redis garantiza que un script corre hasta completarse sin entrelazarse con ningún otro comando):
-- token_bucket.lua-- KEYS[1] = llave del bucket, ej. "ratelimit:{client_id}"-- ARGV[1] = capacity (máximo de fichas que cabe en el bucket)-- ARGV[2] = refill_rate (fichas añadidas por segundo)-- ARGV[3] = now (timestamp unix actual, como float)-- ARGV[4] = requested (fichas que cuesta esta petición, normalmente 1)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", "timestamp")local tokens = tonumber(bucket[1])local last_refill = tonumber(bucket[2])if tokens == nil thentokens = capacitylast_refill = nowendlocal elapsed = math.max(0, now - last_refill)tokens = math.min(capacity, tokens + elapsed * refill_rate)local allowed = 0if tokens >= requested thentokens = tokens - requestedallowed = 1endredis.call("HMSET", key, "tokens", tokens, "timestamp", now)redis.call("EXPIRE", key, math.ceil(capacity / refill_rate) + 1)return {allowed, tokens}
La capa de servicio alrededor carga el script una vez, cachea su SHA, y lo invoca con `EVALSHA` en cada verificación (recurriendo a cargar el script de nuevo si Redis lo desalojó, lo cual se manifiesta como un error `NOSCRIPT`):
const crypto = require("crypto");const fs = require("fs");const script = fs.readFileSync("./token_bucket.lua", "utf8");const scriptSha = crypto.createHash("sha1").update(script).digest("hex");async function checkRateLimit(redis, params) {const bucketKey = "ratelimit:" + params.clientId;const now = Date.now() / 1000;try {const result = await redis.evalsha(scriptSha,1,bucketKey,params.capacity,params.refillRate,now,params.cost || 1);return { allowed: result[0] === 1, remaining: result[1] };} catch (err) {if (err.message && err.message.indexOf("NOSCRIPT") !== -1) {await redis.script("LOAD", script);return checkRateLimit(redis, params);}throw err;}}
#El contrato de respuesta
Sea cual sea el algoritmo detrás, la respuesta HTTP o gRPC del servicio debe permitir que los llamadores se autorregulen: devolver la decisión (`allowed`), la cuota restante, y cuándo puede reintentar el cliente. Sobre HTTP, esto típicamente significa encabezados estándar en cada respuesta — `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` — y un `429 Too Many Requests` con un encabezado `Retry-After` cuando se deniega, para que los clientes bien comportados puedan retroceder en vez de volver a golpear el servicio de inmediato.
#Comparando los algoritmos
| Algoritmo | Manejo de ráfagas | Memoria por llave | Precisión | Uso típico |
|---|---|---|---|---|
| Ventana fija | Permite hasta 2x en el límite de ventana | 1 contador | Baja | Límites simples y gruesos |
| Log de ventana deslizante | Sin ráfaga en el límite | O(peticiones en la ventana) | Exacta | Bajo volumen, alta precisión necesaria |
| Contador de ventana deslizante | Pequeño error de suavizado en el límite | 2 contadores | Buena aproximación | Opción general por defecto |
| Token bucket | Permite ráfagas hasta la capacidad | 2 campos | Buena, modela bien el tráfico real | APIs públicas, cuotas por cliente |
| Leaky bucket | Suaviza ráfagas en una salida constante | Depende de la cola | N/A (moldea, no cuenta) | Proteger un sistema downstream |
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Por qué implementar rate limiting como llamadas separadas de INCR y EXPIRE en Redis desde el código de la aplicación crea una condición de carrera, aunque cada comando sea individualmente atómico?
2. ¿Cuál es la principal ventaja de token bucket sobre un contador de ventana fija para limitar la tasa de una API pública?