Saltar al contenido

Patrones de rate limiting para APIs que escalan

Token buckets, sliding windows y rate limiting distribuido — patrones prácticos para proteger tu API sin degradar la experiencia de los usuarios legítimos.

3 min de lectura
Visualización del algoritmo de token bucket que muestra el flujo de solicitudes a través del rate limiter

Toda API pública necesita rate limiting. Sin él, un solo cliente mal comportado puede saturar tu base de datos, agotar tu presupuesto de cómputo, o tumbar el servicio entero. Pero un rate limiting ingenuo — un simple contador que se reinicia cada minuto — crea efectos de acantilado y penaliza el tráfico legítimo en ráfagas.

Ventana fija: simple pero defectuosa

El enfoque más simple: contar solicitudes por ventana de tiempo. Cuando el contador supera el límite, rechazar.

tstypescript
// ❌ Fixed window — boundary burst problem
async function fixedWindowLimit(
  key: string,
  limit: number,
  windowMs: number,
): Promise<boolean> {
  const window = Math.floor(Date.now() / windowMs);
  const counterKey = `ratelimit:${key}:${window}`;
 
  const count = await redis.incr(counterKey);
  if (count === 1) await redis.pexpire(counterKey, windowMs);
 
  return count <= limit;
}
 
// Problem: at 11:59:59, a client sends 100 requests (under the limit).
// At 12:00:01, they send another 100. Both pass — 200 requests in 2 seconds.

El problema del límite implica que un cliente puede efectivamente duplicar su rate limit sincronizando solicitudes a través de los bordes de la ventana.

Log de ventana deslizante

Registra los timestamps individuales de cada solicitud y cuenta cuántos caen dentro de la ventana deslizante. Más preciso, pero usa más memoria.

tstypescript
async function slidingWindowLog(
  key: string,
  limit: number,
  windowMs: number,
): Promise<boolean> {
  const now = Date.now();
  const windowStart = now - windowMs;
  const sortedSetKey = `ratelimit:${key}`;
 
  // Remove expired entries and add current request atomically
  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(sortedSetKey, 0, windowStart);
  pipeline.zadd(sortedSetKey, now, `${now}:${Math.random()}`);
  pipeline.zcard(sortedSetKey);
  pipeline.pexpire(sortedSetKey, windowMs);
 
  const results = await pipeline.exec();
  const count = results![2][1] as number;
 
  return count <= limit;
}

Esto elimina el problema del límite, pero almacena una entrada por solicitud. Para endpoints de alto tráfico, eso es mucha memoria de Redis.

Token bucket: la mejor opción por defecto

Los token buckets permiten ráfagas mientras hacen cumplir una tasa promedio. Los tokens se agregan a una tasa constante; cada solicitud consume un token. Cuando el bucket está vacío, las solicitudes se rechazan.

tstypescript
// ✅ Token bucket — allows bursts, enforces average rate
async function tokenBucket(
  key: string,
  capacity: number,
  refillRate: number, // tokens per second
): Promise<{ allowed: boolean; remaining: number }> {
  const bucketKey = `bucket:${key}`;
  const now = Date.now();
 
  // Lua script for atomicity
  const script = `
    local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
    local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
    local lastRefill = tonumber(bucket[2]) or tonumber(ARGV[3])
    local capacity = tonumber(ARGV[1])
    local refillRate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    
    local elapsed = (now - lastRefill) / 1000
    tokens = math.min(capacity, tokens + elapsed * refillRate)
    
    local allowed = 0
    if tokens >= 1 then
      tokens = tokens - 1
      allowed = 1
    end
    
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
    redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refillRate) * 1000)
    
    return {allowed, math.floor(tokens)}
  `;
 
  const [allowed, remaining] = (await redis.eval(
    script,
    1,
    bucketKey,
    capacity,
    refillRate,
    now,
  )) as [number, number];
 
  return { allowed: allowed === 1, remaining };
}

Un bucket con capacidad 100 y tasa de recarga de 10/segundo permite una ráfaga de 100 solicitudes, y luego sostiene 10/segundo. Esto se ajusta mejor a los patrones de uso reales que las ventanas fijas.

Elegir una estrategia

AlgoritmoManejo de ráfagasMemoriaPrecisiónComplejidad
Ventana fijaMalo (límite)BajaBajaSimple
Log de ventana deslizanteBuenoAltaAltaMedia
Contador de ventana deslizanteBuenoBajaMediaMedia
Token bucketExcelenteBajaAltaMedia

Token bucket es la opción correcta por defecto para la mayoría de las APIs. La ventana fija es aceptable para servicios internos donde la precisión no importa.

Headers de respuesta y manejo de 429

Comunica siempre el estado del rate limit mediante headers. Los clientes bien comportados los usan para autolimitarse.

tstypescript
function rateLimitResponse(
  res: Response,
  limit: number,
  remaining: number,
  resetAt: number,
) {
  res.setHeader("X-RateLimit-Limit", limit);
  res.setHeader("X-RateLimit-Remaining", Math.max(0, remaining));
  res.setHeader("X-RateLimit-Reset", Math.ceil(resetAt / 1000));
 
  if (remaining < 0) {
    res.setHeader("Retry-After", Math.ceil((resetAt - Date.now()) / 1000));
    return res.status(429).json({
      error: {
        code: "RATE_LIMITED",
        message:
          "Too many requests. Please retry after the Retry-After period.",
      },
    });
  }
}

Rate limits escalonados

Distintos endpoints tienen distintos perfiles de costo. Un endpoint de búsqueda que golpea un índice de texto completo es más costoso que una verificación de estado.

tstypescript
const rateLimits = {
  "GET /api/status": { capacity: 1000, refillRate: 100 },
  "GET /api/search": { capacity: 20, refillRate: 5 },
  "POST /api/orders": { capacity: 10, refillRate: 2 },
  "POST /api/auth/login": { capacity: 5, refillRate: 1 },
} as const;
 
function getRateLimit(method: string, path: string) {
  const key = `${method} ${path}`;
  return rateLimits[key] ?? { capacity: 100, refillRate: 20 };
}

Los endpoints de autenticación merecen los límites más estrictos — son el blanco principal de los ataques de fuerza bruta.

Puntos clave

  1. Token bucket es la mejor opción por defecto — maneja las ráfagas de forma natural mientras hace cumplir tasas promedio
  2. Las ventanas fijas tienen un problema de límite — los clientes pueden duplicar su tasa efectiva en los bordes de la ventana
  3. Usa scripts de Lua en Redis para operaciones de rate limiting atómicas de múltiples pasos
  4. Siempre devuelve headers de rate limit — X-RateLimit-Remaining y Retry-After ayudan a los clientes a autolimitarse
  5. Escalona los rate limits según el costo del endpoint — las operaciones costosas reciben límites más estrictos
  6. Los endpoints de autenticación necesitan los límites más estrictos — son el blanco principal de la fuerza bruta
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX