StackPractices
intermediate Por Mathias Paulenko

Rate Limiting de APIs

Protege las APIs de abuso y asegura uso justo de recursos con token bucket, sliding window y leaky bucket.

Temas: api

Visión General

Un scraper empieza a golpear tu API pública a 2.000 peticiones por segundo. Tu pool de conexiones a la base de datos se satura, los usuarios legítimos empiezan a ver timeouts, y la factura del grupo de autoscaling se triplica de la noche a la mañana. El rate limiting es la línea entre ese incidente y un encogimiento de hombros: un middleware que responde “¿cuántas?” antes de que tus handlers respondan cualquier otra cosa.

Tres algoritmos cubren la mayoría de las necesidades en producción. Token bucket rellena un pool de tokens a una tasa fija. Cada petición gasta un token, así que los bursts cortos pasan mientras la tasa media se mantiene acotada. Sliding window cuenta las peticiones dentro de una ventana temporal deslizante y rechaza todo lo que pase del límite. Estricto, sin picos en los bordes. Leaky bucket drena las peticiones encoladas a una tasa constante, suavizando una entrada irregular en una salida predecible.

Esta receta te da implementaciones funcionales en Python, JavaScript y Java, los trade-offs entre ellas, y los detalles de producción que deciden si tu limiter te protege o se convierte en el outage: política de fallo de Redis, límites por tenant, respuestas 429 correctas, y qué monitorear. Para un recorrido de diseño más profundo, consulta la Guía de Rate Limiting de APIs; para un deep dive solo de Redis, mira Rate Limiting de API con Redis.

Cuándo Usar

Usa rate limiting cuando:

  • APIs públicas necesitan protección contra brute force, scraping y credential stuffing
  • Diferentes tiers de usuarios (anónimos, autenticados, premium) requieren cuotas distintas
  • Múltiples nodos de API deben compartir el estado del rate limit de forma consistente
  • Endpoints costosos (exports, reports) necesitan límites más ajustados que las lecturas baratas

Omítelo para tráfico interno service-to-service de confianza y receptores de webhooks que ya verifican firmas — ver Cuándo No Usar más abajo.

En la práctica, la mayoría de APIs públicas terminan con un modelo por tiers: las peticiones anónimas reciben un techo ajustado por IP, los usuarios autenticados un techo por cuenta según su plan, y los servicios internos un límite eximido o mucho más alto. Diseña primero la key y la tabla de tiers; la elección del algoritmo viene después.

Solución

Python (Token Bucket con Redis)

import time
import redis

r = redis.Redis(host='localhost', port=6379, db=0)

def token_bucket(key, capacity=10, refill_rate=1.0):
    """Token bucket usando un hash de Redis con expiración.

    NOTA: hgetall + hset no es atómico. Dos peticiones concurrentes
    pueden leer el mismo contador y ambas pasar. Para enforcement
    estricto usa un script Lua (ver recursos companion) — la versión
    no atómica se desvía ~1 petición bajo concurrencia, suficiente
    para la mayoría de APIs.
    """
    now = time.time()
    bucket_key = f"rate_limit:{key}"
    tokens = r.hgetall(bucket_key)

    if not tokens:
        r.hset(bucket_key, mapping={"tokens": capacity - 1, "last_refill": now})
        r.expire(bucket_key, int(capacity / refill_rate) + 1)
        return True

    current_tokens = float(tokens.get(b"tokens", 0))
    last_refill = float(tokens.get(b"last_refill", now))
    elapsed = now - last_refill
    current_tokens = min(capacity, current_tokens + elapsed * refill_rate)

    if current_tokens < 1:
        r.hset(bucket_key, mapping={"tokens": current_tokens, "last_refill": now})
        return False

    r.hset(bucket_key, mapping={"tokens": current_tokens - 1, "last_refill": now})
    return True

JavaScript (Sliding Window con Redis)

import { createClient } from 'redis';

const client = createClient({ url: 'redis://localhost:6379' });
await client.connect();

async function slidingWindow(key, limit = 100, windowSec = 60) {
  const now = Date.now();
  const windowStart = now - windowSec * 1000;
  const sortedSetKey = `rate_limit:${key}`;

  // Eliminar entradas fuera de la ventana
  await client.zRemRangeByScore(sortedSetKey, 0, windowStart);

  // Contar entradas actuales
  const count = await client.zCard(sortedSetKey);

  if (count >= limit) {
    return { allowed: false, retryAfter: windowSec };
  }

  // Agregar petición actual
  await client.zAdd(sortedSetKey, [{ score: now, value: `${now}` }]);
  await client.expire(sortedSetKey, windowSec);

  return { allowed: true, remaining: limit - count - 1 };
}

El sorted set guarda un miembro por petición con el timestamp como score. Usa más memoria que un contador: un limiter de 100 req/s ocupa ~128 bytes por key — pero te da una ventana deslizante real en lugar del pico en el borde de la ventana fija. Móntalo como middleware antes de tus route handlers para que las peticiones rechazadas nunca toquen tu lógica de negocio, y devuelve retryAfter tanto en el cuerpo JSON como en el header Retry-After.

Java (Leaky Bucket)

import java.util.concurrent.ConcurrentHashMap;

public class LeakyBucket {
    private static final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();

    static class Bucket {
        final int capacity;
        final double leakRatePerSec;
        double water;
        long lastLeak;

        Bucket(int capacity, double leakRatePerSec) {
            this.capacity = capacity;
            this.leakRatePerSec = leakRatePerSec;
            this.water = 0;
            this.lastLeak = System.currentTimeMillis();
        }

        synchronized boolean allow() {
            long now = System.currentTimeMillis();
            double elapsed = (now - lastLeak) / 1000.0;
            water = Math.max(0, water - elapsed * leakRatePerSec);
            lastLeak = now;
            if (water < capacity) {
                water += 1;
                return true;
            }
            return false;
        }
    }

    public static boolean allowRequest(String clientId, int capacity, double leakRate) {
        return buckets.computeIfAbsent(clientId, k -> new Bucket(capacity, leakRate)).allow();
    }
}

El synchronized en allow() mantiene cada bucket consistente bajo hilos concurrentes; ConcurrentHashMap maneja la búsqueda por cliente. Esta versión vive en memoria; bien para un despliegue de un solo nodo o un sidecar de gateway. Para consistencia multi-nodo moverías las mismas matemáticas de drenado a Redis (o simplemente usarías el sliding window de arriba).

Explicación

La ruta de la petición nunca cambia. El limiter simplemente se sienta delante:

Mermaid flowchart LR diagram

Token bucket mantiene un pool de tokens que se rellena a una tasa fija. Cada petición consume uno; un pool vacío significa rechazo. Los bursts hasta la capacidad pasan, mientras la media a largo plazo se queda en la tasa de relleno. Elígelo para APIs públicas generales donde los clientes ocasionalmente tienen picos.

Sliding window guarda los timestamps de las peticiones de la ventana deslizante actual y rechaza cuando el conteo llega al límite. A diferencia de las ventanas fijas (que dejan pasar 2× el límite en el borde), la ventana se desliza con cada petición. Elígelo para pagos o endpoints sensibles donde el límite debe ser exacto.

Leaky bucket encola las peticiones entrantes y las drena a una tasa constante, como un cubo con un agujero. Los bursts de llegada no importan; la salida siempre es suave. Elígelo cuando proteges un servicio downstream que no puede absorber bursts en absoluto.

Un ejemplo concreto deja clara la diferencia. Un cliente dispara 20 peticiones en un segundo contra un límite de 10/segundo. Con un token bucket de capacidad 10, las primeras 10 pasan al instante y las siguientes 10 se rechazan. El burst era legal, la tasa sostenida no. Con una sliding window al mismo límite, las primeras 10 pasan y el resto espera a que la ventana avance. Con un leaky bucket, las 20 peticiones se encolan y drenan a una por 100ms; ninguna se rechaza, pero la última termina 2 segundos después. Mismo límite, tres experiencias de usuario distintas.

La otra decisión es la key. rate_limit:ip:203.0.113.7 limita una dirección; rate_limit:user:u_4812 limita una cuenta; rate_limit:key:ak_live_9f3 limita una credencial de API. La key correcta suele ser la identidad más específica en la que puedas confiar: una API key para APIs de pago, user ID para tráfico autenticado, IP solo como fallback para endpoints anónimos.

Variantes

AlgoritmoManejo de BurstsMemoriaIdeal Para
Token bucketPermite bursts hasta la capacidadBajaAPIs con tráfico irregular
Sliding windowLímite estricto por ventanaMediaAPIs de pagos, endpoints sensibles
Leaky bucketSuaviza a tasa constanteBajaProtección de servicios downstream

Para el algoritmo token bucket solo en Redis, mira Rate Limiting con Token Bucket en Redis; para una implementación específica de FastAPI, Rate Limiting de API en Python cubre los hooks del framework.

Casos borde que conviene conocer antes de elegir. Fixed window (no mostrada) es la implementación más barata, un INCR más EXPIRE, pero permite que un cliente dispare la cuota completa al final de una ventana y otra vez al inicio de la siguiente, duplicando efectivamente el límite en el borde. Sliding log es la más estricta y la más cara, guardando cada timestamp. Los tres algoritmos de arriba son el punto medio que los sistemas en producción realmente ejecutan.

Lo que Funciona — y lo que Rompe

Hazlo:

  1. Usa token bucket para bursts controlados y sliding window para límites estrictos
  2. Devuelve Retry-After con las respuestas 429 para que los clientes sepan cuándo retroceder
  3. Limita por user ID o API key, no solo por IP. Un NAT corporativo pone miles de usuarios tras una sola dirección
  4. Loguea las violaciones de rate limit para monitoreo de seguridad y detección de abuso
  5. Falla abierto alrededor de Redis: un rate limiter muerto bloqueando todo el tráfico es un outage peor que una API temporalmente sin límite
  6. Diferencia los límites por costo del endpoint: un GET /users es barato; un POST /reports/export no
  7. Guarda los contadores en Redis, no en memoria; los contadores en memoria no sobreviven despliegues multi-instancia
  8. Exime las llamadas internas service-to-service o dales un techo mucho más alto

No lo hagas:

  1. Limitar solo por IP y bloquearás usuarios legítimos tras NAT y no pillarás atacantes con rotación
  2. Devolver 429 sin Retry-After, así que los clientes no pueden implementar backoff correcto y te golpearán más fuerte
  3. Usar un solo rate limit para todos los endpoints sin importar costo o sensibilidad
  4. Dejar que el limiter falle cerrado en silencio. Redis caído + fail closed = outage autoinfligido
  5. Ignorar el log de violaciones. Un pico de 429s es un límite mal configurado o un ataque en curso
  6. Usar ventanas fijas sin jitter, así que todos los clientes reintentan a la vez en el borde de la ventana (thundering herd)
  7. Olvidar la capa de proxy. Tras un load balancer, req.ip es la IP del proxy hasta que configures la confianza en X-Forwarded-For
  8. Aplicar límites a GET pero no a otros métodos, y los atacantes cambian a POST/PUT para evadirlos

Checklist de Producción

  • Rate limits aplicados antes del procesamiento de la petición, no después
  • Respuestas 429 incluyen Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining
  • Contadores con scope por API key o usuario, no solo por IP
  • Endpoints costosos con límites separados y más bajos
  • Límites aplicados a todos los métodos HTTP, incluidos OPTIONS y HEAD
  • trust proxy configurado; X-Forwarded-For validado contra IPs de proxy confiables
  • Política de fallo de Redis elegida deliberadamente (fail open para la mayoría de APIs) con alertas
  • Tráfico interno service-to-service exento o con techo más alto
  • Jitter (delay aleatorio de 0-500ms) en la lógica de reintento del cliente para prevenir estampidas en el borde
  • Configuración de rate limit externalizada (env vars, config service) para hot-reload
  • Límites probados en staging con tráfico concurrente realista
  • Tasa de 429 monitoreada; alerta por encima del ~5% del tráfico total
  • Límites documentados en la documentación de la API y el portal de desarrolladores

Escalado y Rendimiento

EstrategiaOverhead por peticiónThroughput máximoMemoria por key
Token bucket en memoria0.01ms500K req/s64 bytes
Redis fixed window1-2ms50K req/s72 bytes
Redis sliding window2-3ms30K req/s128 bytes
Redis token bucket1-2ms50K req/s64 bytes
Cloudflare edge0ms (en origen)Ilimitado0 bytes

En memoria es 100× más rápido que Redis pero no funciona entre instancias. Para despliegues de una sola instancia es la opción correcta; para cualquier cosa distribuida, Redis es el estándar. El rate limiting en el edge (Cloudflare, Fastly) absorbe el abuso antes de que llegue a tu origen. Para APIs públicas bajo ataque, suele ser lo primero que se compra.

La matemática a escala: cada check cuesta 1-3ms (un round-trip a Redis), y cada check toma ~2 operaciones. A 1M de peticiones/segundo necesitas Redis Cluster con sharding por API key (~2M ops/s en el pico). Si tu presupuesto de latencia es menor a 10ms, usa token buckets locales con sincronización periódica a Redis cada ~100ms. Cambias ~10% de precisión bajo contención por velocidad. En despliegues multi-región, Redis debe estar co-localizado con las instancias de cada región; las llamadas cross-region añaden 50-200ms y anulan el propósito.

ComponenteCostoNotas
Redis (single, 1GB)$10-$25/mesAWS ElastiCache, GCP Memorystore
Redis Cluster (3 shards)$75-$200/mesPara >100K ops/segundo
Cloudflare Rate Limiting$5-$25/mesA nivel edge, sin Redis
AWS API Gateway throttling$0Integrado, límites por stage

Consideraciones de Seguridad

  • Suplantación de keys: los clientes que pueden falsear su IP o user ID evaden el límite. Valida X-Forwarded-For contra las IPs de tus proxies confiables. Confiar ciegamente en él es peor que ignorarlo.
  • DDoS por debajo del límite: los atacantes envían tráfico justo por debajo del límite desde muchas IPs para agotar recursos sin disparar nunca un 429. Combina rate limits con límites de conexiones y de tamaño de cuerpo; el OWASP API Security Top 10 cubre el consumo de recursos sin restricción por algo.
  • Redis como single point of failure: usa modo Sentinel o Cluster. Un limiter que bloquea todo el tráfico cuando el store muere es una pieza crítica de disponibilidad.
  • Fuga de información en headers: X-RateLimit-Limit y X-RateLimit-Remaining revelan tu configuración a los atacantes. Considera omitirlos o aproximarlos para tráfico no autenticado. El emergente estándar de headers RateLimit te da una alternativa más limpia a los headers X- personalizados.
  • Cambio de método: los límites aplicados solo a GET se evaden trivialmente vía POST, PUT o incluso OPTIONS.
  • Variación de path: /api/users?x=1 y /api/users?x=2 son keys distintas para un limiter ingenuo. Normaliza las URLs y limita por patrón de endpoint, no por path completo.
  • Timing attacks: si las peticiones denegadas se resuelven mediblemente más rápido que las permitidas, los atacantes pueden inferir el estado del límite. Devuelve las decisiones tempranas por la misma ruta de código.

Monitoreo y Testing

Vigila cuatro señales. Tasa de respuestas 429: un salto repentino significa un límite mal configurado o un cliente abusivo; alerta por encima del ~5% del tráfico. Latencia de Redis en los checks: si el p95 supera 5ms, tu limiter es ahora el cuello de botella. Crecimiento del conteo de keys: un crecimiento sin límite significa que los clientes generan IDs únicos para evadir límites; pon un techo y expira agresivamente. Fallos del limiter: un limiter caído o un Redis inalcanzable necesita una alerta inmediata, porque acabas de perder tu protección o tu disponibilidad.

Prueba cuatro escenarios. Enforcement: envía N+1 peticiones rápidamente; la (N+1) debe devolver 429 con los headers correctos. Reset de ventana: peticiona en el borde del límite (59.9s, 60.1s) y verifica que el reset se comporta como está documentado. Failover de Redis: mata Redis a mitad del test y confirma que el limiter falla como dice tu política (abierto o cerrado), no al revés. Consistencia distribuida: corre instancias en paralelo y verifica que el límite agregado se mantiene en todas.

Troubleshooting

  • Los límites no se aplican entre instancias. Los contadores están en memoria, no en Redis, así que cada nodo tiene su propio límite. Mueve el estado a un store compartido.
  • Todos reciben 429 tras un proxy. req.ip es la IP del load balancer; configura trust proxy y lee X-Forwarded-For.
  • Usuarios corporativos legítimos bloqueados. Están tras un NAT compartiendo una IP. Cambia la key a user ID o API key.
  • La API se cae cuando Redis se cae. El limiter falla cerrado. Decide tu política deliberadamente: fail open para la mayoría de APIs públicas, fail closed solo para endpoints donde el acceso ilimitado es peor que el downtime.
  • Thundering herd en los bordes de ventana. Las ventanas fijas se resetean para todos a la vez y todos los clientes reintentan simultáneamente. Usa sliding windows o añade jitter a los reintentos.
  • 429s pero nadie respeta Retry-After. Algunos clientes ignoran el header por diseño. Loguea a los infractores y considera bloqueos más largos para reincidentes.

Cuándo No Usar Este Enfoque

  • Microservicios internos con llamadores de confianza — limitar el tráfico inter-servicio añade latencia y riesgo de fallos en cascada sin ganancia de seguridad. Usa cuotas de servicio en el gateway.
  • Receptores de webhooks con verificación de firma — si cada petición pasa un check HMAC, el tráfico inválido ya se rechaza antes de que corra el limiter.
  • APIs de procesamiento por lotes — un batch job enviando 10K peticiones en 10 segundos es comportamiento esperado, no abuso. Usa throttling basado en colas en lugar de límites por petición.

Ver también

Código companion: recursos de rate limiting de API — token bucket atómico con Lua, middleware de Express y una suite de pytest para los escenarios de arriba.

Preguntas frecuentes

¿Debería fallar abierto o cerrado cuando Redis se cae?

Falla abierto para la mayoría de APIs públicas. Rechazar todas las peticiones porque el limiter no está disponible es un outage peor que el tráfico temporalmente ilimitado. Loguea el fallo y alerta sobre él. Falla cerrado solo para endpoints donde el acceso ilimitado es más peligroso que el downtime: operaciones de admin, reset de contraseñas.

¿Cómo manejo rate limiting distribuido entre múltiples nodos?

Usa un store compartido como Redis. Cada nodo verifica e incrementa el mismo contador. Para menor latencia, usa token buckets locales con sincronización periódica a Redis cada ~100ms; aceptas ~10% de imprecisión bajo contención a cambio de no añadir un salto de red a cada petición.

¿Con qué valores de rate limit debería empezar?

100 peticiones/minuto para usuarios autenticados, 10/minuto para tráfico anónimo. Vigila tu tasa de 429 durante dos semanas: si los usuarios legítimos chocan con los límites, súbelos; si nadie los toca nunca, el límite es como si no existiera. Los endpoints costosos (exports, reports) llevan sus propios límites más ajustados.

¿Fixed window o sliding window — cuál elijo?

Sliding window para cualquier cosa de cara al usuario. Las ventanas fijas dejan pasar 2× el límite en el borde (un cliente puede disparar la cuota completa a los 59.9s y otra vez a los 60.1s) y causan reintentos en estampida. La ventana fija solo gana cuando necesitas la implementación más barata posible (un solo INCR + EXPIRE).

¿Cómo aplico rate limiting a una API GraphQL?

Los límites por petición pierden el punto: una query puede costar 50× más que otra. Limita por costo o profundidad de query (asigna puntos por campo, tope de puntos por ventana), o limita en el gateway por nombre de operación para las operaciones caras conocidas.

¿Debería el cuerpo del 429 usar problem details de RFC 9457?

Sí — application/problem+json da a los clientes un type, title, status y detail parseables en lugar de un string ad-hoc. Incluye Retry-After como header (es lo que los intermediarios y las librerías HTTP realmente leen) y pon la explicación humana en detail.