StackPractices
intermediate Por Mathias Paulenko

Limitación de Tasa (Rate Limiting)

Cómo implementar rate limiting en APIs usando token bucket, sliding window y fixed window en Python, JavaScript y Java.

Temas: api

Visión General

El rate limiting controla cuántas peticiones puede hacer un cliente a tu API en una ventana de tiempo dada. Previene abusos, asegura una asignación justa de recursos y protege servicios downstream de sobrecarga — y es una de las pocas defensas que funciona a cualquier escala, desde una API de juguete de un solo proceso hasta una distribuida globalmente.

Esta receta implementa los tres algoritmos que de verdad te vas a encontrar: token bucket (Python, en memoria), fixed window (JavaScript + Redis, distribuido) y sliding window (Java, en memoria). La tabla comparativa bajo el código te dice cuál elegir, y la sección de distribución cubre qué cambia cuando tu API corre en más de un servidor.

Cuándo Usarlo

Usa esta receta cuando:

  • Proteges APIs públicas de abuso o DDoS
  • Aplicas límites de uso por nivel (planes gratis vs de pago)
  • Previenes ataques de fuerza bruta en endpoints de autenticación — consulta JWT Authentication para el lado de auth
  • Gestionas capacidad para operaciones intensivas en recursos
  • Implementas políticas de uso justo entre usuarios

No limites a ojo: elige el límite a partir de datos de uso reales (p95 de clientes legítimos más margen), no de un número redondo que “suena seguro”.

Solución

Python (Token Bucket)

Token bucket es la opción más común para APIs que toleran ráfagas: los tokens se rellenan a ritmo constante y un cliente puede gastarlos más rápido de lo que se rellenan hasta que el cubo se vacía.

import time
from threading import Lock

class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_rate = refill_rate
        self.last_refill = time.time()
        self.lock = Lock()

    def allow(self) -> bool:
        with self.lock:
            now = time.time()
            elapsed = now - self.last_refill
            self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
            self.last_refill = now
            if self.tokens >= 1:
                self.tokens -= 1
                return True
            return False

bucket = TokenBucket(capacity=10, refill_rate=1)
print(bucket.allow())  # True

JavaScript (Fixed Window con Redis)

Fixed window es el algoritmo más simple que funciona entre instancias: un contador por clave y ventana, incrementado atómicamente en Redis. Es el punto de partida correcto para la mayoría de APIs distribuidas — consulta Rate Limiting con Redis para la versión endurecida para producción.

const redis = require('redis');
const client = redis.createClient();

async function rateLimit(key, limit, windowSeconds) {
  const windowKey = `${key}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const current = await client.incr(windowKey);
  if (current === 1) {
    await client.expire(windowKey, windowSeconds);
  }
  return current <= limit;
}

// Middleware de Express — ver la receta de middleware para el patrón
async function limiter(req, res, next) {
  const key = `ratelimit:${req.ip}`;
  const allowed = await rateLimit(key, 100, 60);
  if (!allowed) return res.status(429).json({ error: 'Too many requests' });
  next();
}

Java (Sliding Window)

El log de sliding window guarda un timestamp por petición y elimina el problema de ráfagas en el borde de la ventana, a costa de almacenar cada timestamp.

import java.util.concurrent.*;

public class SlidingWindow {
    private final int capacity;
    private final long windowMs;
    private final ConcurrentLinkedDeque<Long> timestamps = new ConcurrentLinkedDeque<>();

    public SlidingWindow(int capacity, long windowMs) {
        this.capacity = capacity;
        this.windowMs = windowMs;
    }

    public synchronized boolean allow() {
        long now = System.currentTimeMillis();
        while (!timestamps.isEmpty() && now - timestamps.peekFirst() > windowMs) {
            timestamps.pollFirst();
        }
        if (timestamps.size() < capacity) {
            timestamps.addLast(now);
            return true;
        }
        return false;
    }
}

Comparación de Algoritmos

AlgoritmoVentajasDesventajasIdeal para
Fixed WindowSimple, poca memoriaRáfaga en el borde de ventana (hasta 2× el límite)Protección básica, la mayoría de APIs
Sliding WindowTasa suave, sin ráfagas de bordeMás memoria/computaciónControl preciso de tasa
Token BucketPermite ráfagas hasta la capacidadMás difícil de implementar bienAPIs con tolerancia a ráfagas
Leaky BucketTasa de salida estrictamente constanteEncola o descarta el excesoProteger downstreams lentos
Flujo de decisión del limitador — una petición llega al limitador, que consulta el contador compartido; las permitidas siguen al handler, las rechazadas reciben 429 con Retry-After

Elegir un Algoritmo

La tabla se comprime en tres preguntas. ¿Importan las ráfagas? Si un cliente necesita legítimamente 10 peticiones de golpe una vez por minuto, token bucket las absorbe donde fixed window las rechaza. ¿Vale la precisión la memoria? El log de sliding window guarda un timestamp por petición — bien para cientos de peticiones por ventana, derrochador para millones; el contador de sliding window (ponderando la ventana anterior) es el punto medio habitual. ¿Dónde vive el estado? En memoria solo es correcto cuando un proceso sirve el tráfico o los límites son por instancia; en el momento en que existe una segunda instancia, el contador debe moverse a Redis o al edge.

Para la mayoría de APIs la respuesta es: fixed window en Redis primero, token bucket cuando lleguen quejas por ráfagas, sliding window solo cuando alguien pueda nombrar el incidente de ráfaga-de-borde que está resolviendo.

Rate Limiting Distribuido

El ejemplo de JavaScript de arriba es el caso distribuido: Redis guarda el contador, así que cada instancia ve el mismo número. Tres detalles deciden si sobrevive a producción:

  • ¿Fallar abierto o cerrado? Si Redis cae, ¿permites todo (abierto — gana disponibilidad, el abuso se cuela durante la caída) o rechazas todo (cerrado — gana protección, la API muere con Redis)? La mayoría de APIs falla abierto para lectura y cerrado en endpoints de login/pagos. Decídelo explícitamente; no dejes que el manejador de excepciones por defecto decida por ti.
  • Deriva de reloj. La matemática de ventanas usa la hora del servidor; el skew de NTP entre app servers desplaza ligeramente los bordes de ventana. Es inofensivo en ventanas de minutos y solo importa con precisión de sub-segundo.
  • Diseño de claves. ratelimit:{ip} castiga a todos detrás de una NAT o proxy corporativo. Prefiere API key o user ID, con IP como fallback solo para tráfico anónimo — y da a los usuarios autenticados un nivel superior.

Límites por Endpoint

Un único límite global trata igual un GET /status barato que un POST /reports/export caro. En la práctica quieres presupuestos ponderados o por endpoint: lecturas baratas con techo alto, escrituras caras con techo bajo, y endpoints de autenticación (/login, /password-reset) con el límite más estricto de todos — son los que los atacantes martillean de verdad. Impleméntalo como un mapa de ruta a límite, no como N limitadores separados, para que añadir un endpoint sea un cambio de config y no un middleware nuevo.

const LIMITS = {
  'POST:/login': { limit: 5, window: 60 },      // objetivo de fuerza bruta
  'POST:/reports/export': { limit: 10, window: 3600 }, // caro
  default: { limit: 100, window: 60 },
};

async function limiter(req, res, next) {
  const cfg = LIMITS[`${req.method}:${req.route?.path}`] ?? LIMITS.default;
  const key = `ratelimit:${req.user?.id ?? req.ip}:${req.method}:${req.route?.path}`;
  const allowed = await rateLimit(key, cfg.limit, cfg.window);
  if (!allowed) {
    res.set('Retry-After', String(cfg.window));
    return res.status(429).json({ error: 'Too many requests' });
  }
  next();
}

Probar tu Limitador

Los limitadores fallan en silencio — o dejan pasar de más o bloquean usuarios reales, y ninguno de los dos lanza una excepción. Tres tests cubren los modos de fallo: ráfaga hasta el límite y confirma que el 429 llega con Retry-After; espera a que la ventana expire y confirma que el contador se resetea (en tests, encoge la ventana a 1-2 segundos en lugar de dormir un minuto); y mata Redis a mitad de test para confirmar que tu decisión de fail-open/fail-closed es la que realmente tomaste. Una prueba de carga que supere el límite también verifica que el limitador en sí no es el cuello de botella — el check debería añadir milisegundos de un dígito, no un round trip extra completo por petición.

Devolver 429 Correctamente

Un rechazo por rate limit sigue siendo una respuesta de API y merece el mismo cuidado que un 200. Devuelve 429 Too Many Requests con cabecera Retry-After (segundos hasta reintentar, según RFC 6585), e idealmente los campos RateLimit del IETF — RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset — para que los clientes bien educados se auto-limiten en lugar de descubrir el límite tropezando con él. Incluye el límite en tu documentación de API; un límite que nadie conoce se lee como inestabilidad, no como política.

Lo Que Funciona

  • Devuelve 429 con Retry-After — los clientes que saben cuándo reintentar dejan de martillear; los que no, se bloquean igualmente
  • Usa Redis para límites distribuidos — los contadores en memoria duplican silenciosamente el límite por cada instancia que añadas
  • Diferencia por cliente — API key o user ID, no solo IP; las NATs compartidas castigan a usuarios inocentes
  • Nivela por confianza — usuarios autenticados más que anónimos; planes de pago más que gratis
  • Registra los eventos de límite — un pico de 429s es o un ataque o un límite demasiado estricto; conviene saber cuál
  • Dile a los clientes el límite — las cabeceras RateLimit-* convierten el backoff reactivo en ritmo proactivo

Errores Comunes

  • Limitar solo por IP — las NATs corporativas y los gateways de operadoras concentran miles de usuarios detrás de una dirección
  • Contadores en memoria detrás de un load balancer — N instancias significa N veces el límite pretendido
  • Sin plan para cuando cae Redis — una excepción sin manejar mata la API (cerrado por accidente) o desactiva el limitador (abierto por accidente)
  • Límites elegidos por intuición — 100 req/min bloquea un dashboard legítimo que abre 40 llamadas; mide el uso real de clientes primero
  • 429 sin Retry-After — los clientes reintentan inmediatamente, empeorando el problema que defiendes

Solución de Problemas

  • Usuarios legítimos chocan con los límites: comprueba si estás clavando por IP detrás de una NAT/proxy — cambia a API key o user ID, y sube el nivel anónimo solo tras medir.
  • Picos de 429 al inicio del minuto: los clientes sincronizaron sus reintentos con el borde de la ventana — añade Retry-After con jitter, o pasa a sliding window para que el borde desaparezca.
  • El límite se duplicó tras escalar: contador en memoria + segunda instancia — mueve el contador a Redis; cada proceso estaba aplicando su propia copia.
  • Redis caído y la API rechazando todo: el limitador falló cerrado sin querer — envuelve la llamada a Redis en un timeout y decide fail-open vs fail-closed por clase de endpoint.
  • El limitador añade latencia visible: el round trip a Redis está en el hot path — usa un script Lua para hacer check+increment en un solo viaje, o permite una pequeña ráfaga local y reconcilia asíncronamente.

Lecturas Adicionales

Preguntas frecuentes

¿Debería limitar en el edge o en la aplicación?

En ambos. Los límites de edge/CDN (Cloudflare, AWS WAF) absorben DDoS y floods de credential stuffing antes de que lleguen a tu infraestructura. Los límites a nivel de aplicación aplican la lógica de negocio — cuotas por plan, endpoints caros, uso justo. El edge solo no puede ver la identidad del usuario; la app sola quema cómputo en tráfico que podrías haber descartado antes.

¿Qué código de estado HTTP debería devolver al limitar?

429 Too Many Requests, definido por el RFC 6585, con cabecera Retry-After indicando los segundos hasta que el cliente puede reintentar. Los campos RateLimit-* del IETF además te permiten anunciar el límite, la cuota restante y el tiempo de reset para que los clientes se auto-limiten.

¿Cómo limito sin Redis en un sistema distribuido?

Las sticky sessions fijan cada cliente a una instancia (frágil — un rebalanceo resetea su presupuesto). Un contador en la base de datos compartida funciona pero añade latencia y carga de escritura. La respuesta honesta es que el rate limiting distribuido quiere un almacén compartido rápido; Redis es el estándar porque INCR + EXPIRE es atómico y barato.

¿Qué algoritmo elijo para una API REST típica?

Fixed window en Redis. Son dos comandos de Redis, suficientemente correcto para la mayoría de límites, y la ráfaga de borde que permite (2× en el filo de la ventana) raramente importa en ventanas de minutos. Pasa a token bucket cuando los usuarios se quejen de rechazos en ráfaga, o a sliding window cuando la precisión realmente cueste dinero.

¿Cómo gestiono la limitación para usuarios autenticados vs anónimos?

Dos niveles con claves distintas: ratelimit:user:{id} con límite generoso para autenticados, ratelimit:ip:{ip} con uno más estricto para anónimos. Nunca apliques límites anónimos a usuarios autenticados — una IP de oficina compartida no debería estrangular la cuenta de un cliente que paga.

¿El rate limiting protege contra DDoS?

Contra floods pequeños y abuso, sí — capea el throughput por cliente. Contra DDoS volumétrico ayuda solo indirectamente: el limitador en sí cuesta una llamada a Redis por petición, y a escala de DDoS real el propio check se convierte en el cuello de botella. La defensa volumétrica vive en la capa CDN/edge; el rate limiting es la segunda línea.