Capítulo 07 · El portero
Rate limiting
Cuántas veces podés llamar a la puerta. Login, 429, token bucket. Redis otra vez, otro job.
17 min intermedio
Alguien golpea el login
Imaginate que seguimos en la misma tienda. El pedido vive en Postgres. El tráfico entra por el load balancer. Las fotos van por el CDN. La ficha se sirve desde caché. El último 42 se aparta con un lock. Ninguna de esas piezas responde a esto: un script prueba contraseñas contra POST /login. Diez mil intentos por minuto, todos desde la misma IP, todos sobre la cuenta de Ana.
Sin límite, dos cosas pasan a la vez:
- Postgres y bcrypt trabajan para el atacante: cada intento es CPU y disco regalados.
- Ana — o el resto de la tienda — compite con ese fuego. El login se pone lento. A veces cae.
El checkout no es inmune. Un bot refresca Comprar. Un cliente mal programado reintenta el pago en un bucle. Y Stripe también te ratea a vos: si tus workers no se limitan antes, su 429 se convierte en tu incidente.
Qué es (y qué no es)
Rate limiting es una regla simple: esta clave, en esta ventana de tiempo, como máximo N veces. Si pasás de N, la API responde 429 Too Many Requests, a menudo con un header Retry-After que dice cuánto esperar. Es el portero de la puerta: cuenta golpes, no decide quién se queda el último 42.
Importa no confundirlo con lo que ya vimos.
No es un lock. El lock del 42 dice «este par es de Ana diez minutos». El límite dice «esta IP (o este user, o esta API key) ya llamó bastante». Uno coordina un recurso escaso. El otro frena volumen.
Tampoco es el backpressure de las colas. Allí frenabas porque los workers no daban abasto: el trabajo ya había nacido. Acá frenás antes de que el trabajo nazca, aunque el cluster esté holgado. Protegés abuso, coste y a los vecinos — Ana no debería pagar el script que martilla el login.
La pregunta de diseño es la clave
Antes del algoritmo, la pregunta útil es: ¿de quién estás contando?
Por IP en el login. El script se nota: una dirección dispara miles de intentos. El precio de esa clave es real: un edificio entero o un móvil detrás de CGNAT comparten IP. Limitar demasiado duro echa a gente inocente con un 429 injusto.
Por user id cuando ya hay sesión. El checkout de Ana no debería tumbar el de Luis. Si limitás Comprar solo por IP, una oficina entera detrás del mismo NAT se pisa sola.
Por API key si vendés una API a terceros: el plan gratis son 100 req/min; el de pago, más. La key es el contrato.
A veces combinás. Login duro por IP y por email: el script que rota IPs pero insiste sobre ana@… igual se corta. Reset de password, igual de estricto. En una entrevista de system design, nombrar la clave demuestra más criterio que nombrar «token bucket» de entrada.
Y hay un anti-patrón de tienda. El 123 se viraliza. Si rateás GET /products/123 a 10/min por IP, echás a clientes reales en Black Friday. Ese pico no se arregla con el portero: se arregla con caché. El rate limit en lecturas públicas, si existe, es alto y sirve contra scrapers, no contra demanda legítima. En writes sí apretás: login, reset de password, Comprar, «enviar otra vez el email de factura».
Dónde lo ponés
Dos sitios, dos jobs.
En el API gateway (o el edge): un número gordo y uniforme. «Esta IP, como máximo 1000 req/min a toda la API». Barato, temprano, no sabe qué es un pedido.
En la aplicación: reglas de negocio. «3 intentos de pago por order_id». «5 resets de password por email / hora». Un gateway que no entiende pedidos no puede limitar por pedido. La regla vive cerca del dominio.
En la pizarra suele haber los dos: tosco delante, fino detrás.
Cómo funciona (el camino feliz)
La versión más simple de contar, compartida entre todas las APIs, vive en Redis — la misma caja de la ficha y del lock, otro job: ahora es un contador.
INCR ratelimit:login:ip:1.2.3.4
EXPIRE ... 60 -- solo si acabás de crear la key
Paso a paso, en el login:
- Llega
POST /logindesde la IP1.2.3.4. - La API hace
INCRsobreratelimit:login:ip:1.2.3.4. - Si la key era nueva, pone un
EXPIREde 60 segundos (la ventana). - Si el valor es ≤ N, sigue: bcrypt, Postgres, cookie.
- Si el valor es > N, responde
429conRetry-After. No llegás a bcrypt.
Cliente → API → Redis INCR
│ ≤ N → login normal
│ > N → 429 + Retry-After
Tres APIs detrás del load balancer. Si cada una cuenta en su memoria, Ana hace 60 en cada una: 180. El límite tiene que ser compartido. Un contador local no es un límite: es una sugerencia.
El detalle del EXPIRE: ponelo cuando la key es nueva (cuando INCR devuelve 1). Si lo renovás en cada request, un fuego constante empuja el reloj para adelante y la ventana nunca cierra. En la pizarra: atomic incr, TTL = window, compare to N. En producción, INCR + EXPIRE suelen ir en un script corto para no dejar una key eterna si crasheás en el medio.
Ventanas que mienten, cubos que gotean
El contador de arriba es una fixed window: «en este minuto, 60 requests». Simple. También miente un poco en el borde. A las 12:00:59 disparás 60. A las 12:01:00 la ventana nueva empieza en cero y disparás otras 60. En dos segundos hiciste 120. Vale para un primer corte. No vale si alguien puede abusar del borde a propósito.
Sliding window mira los últimos 60 segundos de verdad, no el reloj de pared redondo. Más justo; un poco más de trabajo (a veces un sorted set en Redis con timestamps).
Token bucket es el que mejor se cuenta en voz alta, y el que suele querer oír el entrevistador si pedís un default. Pensalo así:
- Un cubo con capacidad 10 (el burst).
- Cada segundo entra 1 token (el ritmo sostenido).
- Cada request gasta un token.
- Si el cubo está lleno, Ana puede pegar un pico corto: abrió la app, cargó home + ficha + carrito, diez golpes seguidos. Bien.
- Si el cubo está vacío,
429. Un script que tira sin parar se queda sin tokens y se corta.
Encaja con humanos: un pico corto sí, un fuego constante no. Leaky bucket es el primo: sale a ritmo fijo; lo que no cabe espera o se tira. Más rígido con los bursts.
No memorices cuatro nombres para recitarlos. Di una frase como esta: I want a burst of a few logins and then a hard cap; token bucket in Redis, keyed by IP for /login.
Problemas típicos
Redis cae: fail open o fail closed
Sin contador, ¿qué hacés?
Fail closed: nadie pasa. El login se siente caído. Seguro frente al script; malo para Ana un martes cualquiera.
Fail open: todos pasan. La tienda vive; el script también.
No hay respuesta única. El login suele fail closed, o con un límite local de emergencia muy bajo en cada API («al menos no son diez mil bcrypt por segundo»). El GET de ficha, fail open: preferís servir el catálogo. En una entrevista de system design o en una entrevista técnica, elegí por ruta y di por qué.
La hot key del botnet
Una IP (o un puñado) martillea ratelimit:login:ip:…. Redis aguanta mucho, pero una sola key caliente es el mismo fenómeno que la ficha viral: un clavo. La buena noticia: el 429 es barato — no llegás a bcrypt ni a Postgres. Si hace falta, el gateway corta tosco por IP antes del límite fino en la app.
429 no es el final de la historia
El cliente decente lee Retry-After y hace backoff — el mismo espíritu del jitter en las colas. El bot no. El rate limit no sustituye captcha, WAF ni bloquear IPs sucias; se lleva bien con ellos. En APIs de partners, el 429 es contrato. En el checkout, un 429 a un humano pide un mensaje claro («esperá un momento»), no un JSON crudo de infraestructura.
A veces no tirás la request: la encolás. «Te mandamos el PDF de la factura cuando toque». El límite protege la entrada síncrona; la cola alisa lo que puede esperar. No metas en cola el login. Sí el «enviar factura otra vez».
Ellos también te limitan a vos
Stripe, SMS, el proveedor de email: tienen su portero. Un rate limiter de salida en tus workers evita que un retry storm agrave su 429 y te convierta el pico en un bloqueo más largo. Misma idea, otra dirección: no solo cuidás la puerta de entrada; cuidás cuánto golpeás las puertas ajenas.
La tienda, con portero
Login: 5 intentos / 15 min por IP y por email. Token bucket o sliding window. Fail relativamente cerrado. Checkout: unas pocas compras por user por minuto — no por IP, para no castigar una oficina. Email de factura: 3 al día por pedido. GET de ficha: límite alto o ninguno, caché delante. Workers hacia Stripe: un cubo global de salida para no quemar su cuota.
POST /login
→ Redis: ratelimit:login:ip:… (+ email)
→ ≤ N → bcrypt + Postgres
→ > N → 429 + Retry-After
GET /products/123
→ caché primero; rate limit alto o ausente
Workers → Stripe
→ cubo de salida (no agravar su 429)
Todo el contador compartido en Redis, keys con TTL, respuesta con Retry-After. El lock del 42 sigue aparte: Luis no reserva el par; eso no es un 429, es «esta talla está apartada».
Qué espera cada nivel
Un mid pone un 429, un contador en Redis (INCR + TTL) y una ventana. Sabe que el límite tiene que ser compartido entre instancias.
Un senior elige la clave (IP vs user vs API key), distingue lecturas cacheadas de writes, coloca tosco en gateway y fino en app, y dice fail open o closed según la ruta — login ≠ ficha.
Si te lo preguntan
¿Cómo limitás el login? I’d rate-limit login by IP and by email, token bucket in Redis so all API instances share the count. Over the limit we return 429 with Retry-After. Product reads are cached, not squeezed — Black Friday is not an attack. If Redis is down, login fails closed or uses a tight local cap; catalog reads fail open.
¿Fixed o sliding? Fixed is simple and bursts at the window edge. I’d use sliding or a token bucket if fairness matters.
¿Por qué Redis? The limit has to be global across instances. Memory on one box is not a limit, it’s a suggestion.
¿En el gateway? Yes for coarse caps. Business rules — per order, per user plan — live in the app.
¿Token bucket? Burst capacity plus a steady refill. Humans get a short spike; a script running flat out empties the bucket and gets 429.