StackPractices
intermediate Por Mathias Paulenko

Implementar Rate Limiting de APIs con Redis

Protege APIs del abuso con limitadores token bucket y ventana deslizante respaldados por Redis, con ráfagas controladas y coordinación distribuida

Las APIs públicas atraen abuso: scrapers, intentos de fuerza bruta y clientes que ignoran los reintentos amistosos. Un rate limiter respaldado por Redis mantiene el estado de las cuotas en un solo lugar, así que cada nodo de la API aplica los mismos límites. Esta receta implementa los dos algoritmos más usados — token bucket y ventana deslizante — con scripts Lua atómicos, configuración por tier y los headers de respuesta que los clientes necesitan para retroceder correctamente. Para el lado defensivo del throttling (qué capa bloquea qué, cómo identificar clientes), consulta la Guía de Buenas Prácticas de Seguridad.

Cuándo Usar Esto

  • APIs públicas que necesitan protección contra fuerza bruta y scraping
  • Distintos tiers de usuarios que requieren límites diferentes (free vs paid)
  • Múltiples nodos de API que deben compartir el estado del rate limit de forma consistente
  • Endpoints de webhooks o callbacks que reciben ráfagas impredecibles de sistemas externos
  • Sáltalo si una API de una sola instancia y poco tráfico es suficiente: un limiter en memoria evita el salto de red extra a Redis en cada petición

Solución

1. Algoritmo de Token Bucket

// rate-limiter/TokenBucket.ts
import { Redis } from 'ioredis';

class TokenBucketLimiter {
  private redis: Redis;

  constructor(redis: Redis) {
    this.redis = redis;
  }

  async check(key: string, capacity: number, refillRate: number): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
    const bucketKey = `ratelimit:token:${key}`;

    const luaScript = `
      local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
      local capacity = tonumber(ARGV[1])
      local refillRate = tonumber(ARGV[2])
      local interval = tonumber(ARGV[3])
      local time = redis.call('TIME')
      local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)

      local tokens = tonumber(bucket[1]) or capacity
      local lastRefill = tonumber(bucket[2]) or now

      local elapsed = now - lastRefill
      local refill = math.floor(elapsed / interval) * refillRate
      tokens = math.min(capacity, tokens + refill)

      if tokens >= 1 then
        tokens = tokens - 1
        redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
        redis.call('EXPIRE', KEYS[1], 3600)
        return {1, tokens, now + interval}
      else
        redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', lastRefill)
        redis.call('EXPIRE', KEYS[1], 3600)
        return {0, tokens, lastRefill + interval}
      end
    `;

    const result = await this.redis.eval(luaScript, 1, bucketKey, capacity, refillRate, 1000);
    const [allowed, remaining, resetTime] = result as [number, number, number];

    return {
      allowed: allowed === 1,
      remaining,
      resetTime,
    };
  }
}

Leer TIME dentro del script Lua usa el reloj del servidor Redis, así que cada nodo de la app mide el tiempo transcurrido contra la misma fuente, sin deriva por diferencias de reloj entre instancias.

2. Contador de Ventana Deslizante

// rate-limiter/SlidingWindow.ts
class SlidingWindowLimiter {
  private redis: Redis;

  constructor(redis: Redis) {
    this.redis = redis;
  }

  async check(key: string, limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
    const now = Date.now();
    const windowKey = `ratelimit:window:${key}`;
    const windowStart = now - windowMs;

    const pipeline = this.redis.pipeline();
    pipeline.zremrangebyscore(windowKey, 0, windowStart);
    pipeline.zcard(windowKey);
    pipeline.zadd(windowKey, now, `${now}-${Math.random()}`);
    pipeline.pexpire(windowKey, windowMs);

    const results = await pipeline.exec();
    // exec() devuelve tuplas [err, result]: el índice 1 contiene el count de ZCARD
    const currentCount = (results?.[1]?.[1] ?? 0) as number;

    if (currentCount < limit) {
      return {
        allowed: true,
        remaining: limit - currentCount - 1,
        resetTime: now + windowMs,
      };
    }

    // Quita la petición que acabamos de añadir
    await this.redis.zremrangebyrank(windowKey, -1, -1);

    const oldest = await this.redis.zrange(windowKey, 0, 0, 'WITHSCORES');
    const resetTime = oldest.length > 0 ? Number(oldest[1]) + windowMs : now + windowMs;

    return {
      allowed: false,
      remaining: 0,
      resetTime,
    };
  }
}

3. Middleware de Express con Headers

// middleware/rateLimit.ts
import { Request, Response, NextFunction } from 'express';

function rateLimit(limiter: TokenBucketLimiter, options: { capacity: number; refillRate: number }) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = `${req.ip}:${req.route?.path || req.path}`;
    const result = await limiter.check(key, options.capacity, options.refillRate);

    res.setHeader('RateLimit-Limit', String(options.capacity));
    res.setHeader('RateLimit-Remaining', String(Math.max(0, result.remaining)));
    res.setHeader('RateLimit-Reset', String(Math.ceil(result.resetTime / 1000)));

    if (!result.allowed) {
      res.setHeader('Retry-After', String(Math.ceil((result.resetTime - Date.now()) / 1000)));
      res.status(429).json({
        error: 'Too Many Requests',
        retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000),
      });
      return;
    }

    next();
  };
}

// Uso
app.use('/api/public', rateLimit(limiter, { capacity: 100, refillRate: 10 }));
app.use('/api/premium', rateLimit(limiter, { capacity: 1000, refillRate: 100 }));

Los headers RateLimit-* siguen el draft del IETF para campos de rate limit; la convención anterior X-RateLimit-* sigue funcionando, pero la forma sin prefijo es la que parsean los clientes y gateways modernos.

4. Configuración por Tier de Usuario

// rate-limiter/TierConfig.ts
interface RateLimitConfig {
  capacity: number;
  refillRate: number;
}

const tierLimits: Record<string, RateLimitConfig> = {
  free: { capacity: 100, refillRate: 10 },
  pro: { capacity: 1000, refillRate: 100 },
  enterprise: { capacity: 10000, refillRate: 1000 },
};

async function getUserTier(userId: string): Promise<string> {
  // Obtener de la base de datos o caché
  return 'pro';
}

// Middleware con consulta de tier
async function tieredRateLimit(req: Request, res: Response, next: NextFunction) {
  const userId = req.headers['x-user-id'] as string;
  const tier = userId ? await getUserTier(userId) : 'free';
  const config = tierLimits[tier] || tierLimits.free;

  const key = `${tier}:${userId || req.ip}`;
  const result = await limiter.check(key, config.capacity, config.refillRate);

  if (!result.allowed) {
    res.status(429).json({ error: 'Rate limit exceeded', tier });
    return;
  }

  next();
}

Variante en Python (redis-py)

El mismo script Lua corre desde Python con register_script, que lo guarda en el caché del servidor y envía EVALSHA en las llamadas siguientes. El tiempo sigue viniendo de redis.call('TIME'), así que todos los nodos miden contra el mismo reloj:

# rate_limiter.py (pip install redis)
import redis

TOKEN_BUCKET_LUA = """
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[1])
local last   = tonumber(redis.call('HGET', KEYS[1], 'last') or '0')
local t      = redis.call('TIME')
local now    = t[1] + t[2] / 1000000

tokens = math.min(tonumber(ARGV[1]), tokens + (now - last) * tonumber(ARGV[2]))
local allowed = tokens >= 1
if allowed then tokens = tokens - 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'last', now)
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return allowed and 1 or 0
"""

def make_limiter(client: redis.Redis):
    script = client.register_script(TOKEN_BUCKET_LUA)

    def check(key: str, capacity: int, refill_rate: float) -> bool:
        ttl_ms = int(capacity / refill_rate * 2000)
        return bool(script(keys=[f"rate:{key}"], args=[capacity, refill_rate, ttl_ms]))

    return check

Para una implementación completa en Python, consulta Rate Limiting de API en Python con Token Bucket.

Cómo Funciona

  • Token bucket permite ráfagas hasta la capacidad mientras mantiene la tasa promedio
  • Ventana deslizante da límites más estrictos al registrar los timestamps exactos de cada petición
  • Los scripts Lua de Redis hacen atómico el check-and-decrement entre nodos distribuidos
  • Los headers de respuesta informan al cliente de su cuota restante y cuándo se reinicia la ventana
flowchart diagram: Petición del cliente

Cómo Elegir el Algoritmo

Las cuatro estrategias de ventana habituales difieren en coste de memoria y rigurosidad:

AlgoritmoMemoria por clientePrecisiónMejor para
Token bucketO(1) campos de hashTasa promedio; permite ráfagas hasta la capacidadLa mayoría de las APIs — tasa estable con ráfagas controladas
Fixed window counterO(1), una key INCRFallo en el borde: hasta 2x el límite en el límite de la ventanaLímites de baja precisión donde prima la simplicidad
Sliding window counterO(1), dos contadoresAproximado (ventana previa ponderada)Más precisión que fixed window a coste O(1)
Sliding window logO(n), una entrada por peticiónConteo exacto en cualquier ventana móvilAPIs de compliance que deben aplicar conteos exactos

El sliding window log guarda una entrada en el sorted set por petición (ZADD rate:log:{key} <ts> <requestId>, se poda con ZREMRANGEBYSCORE y se cuenta con ZCARD), así que la memoria crece con el tráfico; para endpoints de alto volumen prefiere token bucket. Sin Redis, el mismo token bucket puede correr sobre PostgreSQL con SELECT FOR UPDATE o sobre DynamoDB con escrituras condicionales, al coste de un round trip a la base de datos por check; las reglas a nivel CDN (rate limiting de Cloudflare, reglas rate-based de AWS WAF) también pueden bloquear el abuso antes de que llegue al servidor.

Consideraciones de Producción

  • Usa Redis Cluster para el estado de rate limit en alta disponibilidad; los hash tags como rate:{tenantId}:… mantienen las keys de un cliente en el mismo slot para que las operaciones Lua multi-key sigan siendo atómicas
  • Implementa un circuit breaker alrededor de Redis para fallar abierto (fail open) si el caché cae
  • Registra las violaciones de rate limit para monitoreo de seguridad y detección de abuso

Errores Comunes

  • Limitar solo por IP, lo que bloquea usuarios legítimos detrás de NAT: combina IP con API key o user ID cuando estén disponibles
  • No manejar los fallos de Redis con elegancia, provocando caídas de la API cuando el caché va lento o no responde
  • Devolver 429 sin Retry-After, dejando a los clientes adivinando cuándo reintentar
  • Hacer SET + EXPIRE como llamadas separadas; sin Lua ni MULTI el check-and-decrement no es atómico y las peticiones concurrentes se pasan del límite
  • Olvidar el TTL en las keys de rate limit: los clientes inactivos mantienen las keys vivas para siempre y la memoria de Redis crece sin freno

Solución de Problemas

  • Todos los clientes reciben 429 justo después de un despliegue: comprueba si cambió el prefijo de las keys o el layout de hash tags: un prefijo nuevo reinicia los contadores y hace que clientes sanos parezcan limitados
  • Usuarios detrás del mismo proxy corporativo se limitan juntos: estás limitando por IP; cambia a API key o user ID, o sube los límites en endpoints con mucho NAT
  • La latencia p99 se dispara cuando Redis va lento: fija un timeout de comandos (~50ms) y falla abierto; inspecciona los EVALSHA lentos con SLOWLOG GET
  • Las keys de rate limit no expiran nunca: audita con SCAN + TTL: cada comando mutador del script debe establecer también EXPIRE/PEXPIRE
  • Límites inconsistentes entre nodos: los relojes de los servidores de app derivan; lee redis.call('TIME') dentro del script Lua en lugar de pasar Date.now() desde cada instancia

Lectura Adicional

Preguntas frecuentes

¿Token bucket o ventana deslizante: cuál debería usar?

Token bucket permite ráfagas controladas y sale más barato en Redis (una llamada Lua por petición). La ventana deslizante es más estricta y conviene cuando hay que garantizar conteos exactos en una ventana móvil — piensa en APIs financieras con requisitos de compliance. Para la mayoría de las APIs, un token bucket con ráfagas de 2x la tasa estable es suficiente.

¿Qué pasa cuando Redis cae — se rompe la API?

Solo si fallas cerrado (fail closed). Envuelve las llamadas a Redis en un circuit breaker: cuando Redis no responde, falla abierto (deja pasar la petición) y registra un aviso: fallar cerrado convierte una caída del caché en una caída total de la API. Para cortes breves, usa un contador en memoria como lru-cache. Fija un timeout de ~50ms en los comandos para que un Redis lento no bloquee el procesamiento, y alerta mientras el breaker esté abierto.

¿Cómo configuro límites por tier (free, pro, enterprise)?

Lleva el tier en la API key o en los claims del JWT y crea un bucket por tier: rate:free:{key}, rate:pro:{key}, rate:enterprise:{key}, cada uno con su capacidad y refill rate. Devuelve el tier en un header de respuesta (RateLimit-Tier: pro) para que el cliente pueda depurar sus límites. Lee la configuración desde una base de datos o servicio de config para cambiar límites sin redesplegar.

¿Cómo gestiono el rate limit entre microservicios?

Comparte una instancia de Redis (o un servicio dedicado de rate limiting) y que cada servicio consulte el limiter antes de procesar. Para llamadas service-to-service, da a cada par de servicios su propio bucket para que un downstream no agote la cuota de otro. Un API gateway como Kong o Envoy puede aplicar los límites de forma centralizada. En Redis Cluster, usa hash tags para que las keys de cada cliente caigan en el mismo slot.

¿Puedo limitar por algo distinto a la IP?

Sí — y normalmente deberías. Usa como key la API key (rate:apikey:{sha256(apiKey)}), el user ID en tráfico autenticado, o un namespace por tenant en APIs multi-tenant (rate:tenant:{tenantId}:{key}). Una división habitual: rate:ip:{ip} a 10 req/min para peticiones anónimas y rate:user:{userId} a 100 req/min para autenticadas. Hashea las API keys antes de usarlas como keys de Redis.

¿Qué headers debería devolver en un 429?

Como mínimo Retry-After (segundos hasta poder reintentar). En respuestas normales devuelve RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset (timestamp Unix) según el draft del IETF, o los equivalentes heredados X-RateLimit-* si tus clientes ya los parsean. No expongas la configuración interna, como el tamaño del bucket o el refill rate.

¿Cómo limito conexiones WebSocket?

Limita por connection ID, no por IP. Registra las conexiones activas por usuario en un sorted set (ZADD ws:connections <ts> <connectionId>), pon un tope de conexiones por usuario y cuenta los mensajes por conexión con un token bucket en rate:ws:{connectionId}. Cierra los clientes que abusan con el close code 1008 (policy violation) y limpia las entradas al desconectar con ZREM.

¿Cómo limito APIs GraphQL?

Limita por coste de query, no por número de peticiones: una sola query puede costar tanto como 100 llamadas REST. Puntúa los campos por complejidad, calcula el total antes de ejecutar (el paquete graphql-cost-analysis lo hace) y acumula el coste en Redis con INCRBY rate:gql:{userId} <cost>. Las persisted queries permiten cachear el coste calculado. Devuelve headers RateLimit-Cost para que el cliente vea lo que gasta cada query.

¿Cómo pruebo la lógica de rate limiting?

Prueba el script Lua directamente con redis-cli --eval contra keys de prueba. Cubre los casos de borde: justo en el límite, uno por encima y el refill tras esperar. Para tests del middleware usa ioredis-mock o un Redis real en un contenedor testcontainers, lanza N peticiones rápidas y comprueba que la N+1 devuelve 429 con header Retry-After. Verifica la atomicidad con concurrencia via Promise.all y corre k6 o artillery para confirmar que el limiter aguanta el throughput esperado.

¿Cómo monitorizo y alerto sobre el rate limiting?

Sigue cuatro números: peticiones totales, respuestas 429, clientes más limitados y latencia de comandos Redis. Alerta cuando los 429 superen el ~5% del tráfico: o los límites son demasiado estrictos, o un cliente se porta mal. Vigila la latencia de EVALSHA; pasar de ~10ms indica que el script o Redis necesitan atención. Alerta cuando el circuit breaker se abra, porque ahí es cuando el rate limiting se apaga en silencio.