StackPractices
intermediate Por Mathias Paulenko

Implementar Rate Limiting para APIs y Aplicaciones Web

Cómo proteger APIs y endpoints web del abuso usando estrategias de rate limiting token bucket, sliding window y fixed window con implementaciones en Redis y memoria.

Temas: security

Visión general

El rate limiting es una técnica defensiva que controla cuántas peticiones puede realizar un cliente a una API o endpoint web dentro de una ventana de tiempo determinada. Sin rate limiting, un solo cliente que se comporta mal — ya sea malicioso o accidentalmente con bugs — puede agotar recursos del backend, privar a usuarios legítimos y desencadenar fallos en cascada a través de sistemas distribuidos.

El rate limiting útil se implementa en múltiples capas: API gateway (edge), middleware de aplicación (servicio) y base de datos (throttling de queries). Cada capa usa diferentes algoritmos adecuados a diferentes trade-offs. Token bucket permite ráfagas, sliding window proporciona precisión, y fixed window es simple pero vulnerable a avalanchas en los límites de la ventana. Aqui se explica como implementaciones desde in-memory de nodo único hasta limitación distribuida respaldada por Redis.

Cuándo usarlo

Usa esta receta cuando:

  • Protegiendo APIs públicas del abuso, scraping o ataques de fuerza bruta
  • Limitando operaciones costosas para prevenir sobrecarga del backend
  • Haciendo cumplir límites de uso por tier para usuarios gratuitos vs pagados
  • Previniendo fallos en cascada durante picos de tráfico o eventos DDoS
  • Cumpliendo con requisitos de SLA para asignación justa de recursos

Solución

Token Bucket (Python / Redis)

import time
import redis

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

def is_allowed(key: str, capacity: int, refill_rate: float) -> bool:
    now = time.time()
    pipe = r.pipeline()
    pipe.hmget(key, ['tokens', 'last_refill'])
    pipe.expire(key, 60)
    result = pipe.execute()

    tokens = float(result[0][0]) if result[0][0] else capacity
    last_refill = float(result[0][1]) if result[0][1] else now

    elapsed = now - last_refill
    tokens = min(capacity, tokens + elapsed * refill_rate)

    if tokens >= 1:
        tokens -= 1
        r.hmset(key, {'tokens': tokens, 'last_refill': now})
        r.expire(key, 60)
        return True
    else:
        r.hmset(key, {'tokens': tokens, 'last_refill': last_refill})
        r.expire(key, 60)
        return False

# Uso
if not is_allowed('user:123', capacity=10, refill_rate=1):
    return Response(status=429, body=b"Rate limit exceeded")

Sliding Window Log (Node.js / En memoria)

const requests = new Map(); // userId -> [timestamps]

function isAllowed(key, limit, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;

  if (!requests.has(key)) {
    requests.set(key, [now]);
    return true;
  }

  const timestamps = requests.get(key).filter(t => t > windowStart);
  timestamps.push(now);
  requests.set(key, timestamps);

  return timestamps.length <= limit;
}

// Middleware Express
function rateLimitMiddleware(req, res, next) {
  const key = req.ip;
  if (!isAllowed(key, 100, 60000)) {
    return res.status(429).json({ error: 'Too many requests' });
  }
  next();
}

Fixed Window (Nginx)

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
  location /api/ {
    limit_req zone=api burst=20 nodelay;
    proxy_pass http://backend;
  }
}

Explicación

  • Token bucket: un bucket contiene un número fijo de tokens. Cada petición consume un token. Los tokens se recargan a tasa constante. Esto permite ráfagas controladas mientras se hace cumplir una tasa promedio a lo largo del tiempo. Ideal para APIs que toleran picos cortos.
  • Sliding window log: En cada petición, poda entradas fuera de la ventana actual y cuenta el restante. El más preciso pero intensivo en memoria a gran escala.
  • Fixed window: divide el tiempo en ventanas discretas (por ejemplo, buckets de 1 minuto). Un contador se incrementa por ventana. Simple y eficiente en memoria, pero una ráfaga en el límite de la ventana cuenta dos veces.
  • Limitación distribuida: los contadores en memoria son rápidos pero fallan entre múltiples instancias de servidor. Redis proporciona estado compartido con operaciones atómicas (INCR, EXPIRE, scripts Lua) para rate limiting distribuido.

Variantes

AlgoritmoTolerancia a ráfagasUso de memoriaDistribuidoMejor para
Token bucketBajoRedisAPIs con tolerancia a ráfagas
Sliding windowNoAltoRedisLímites estrictos por segundo
Fixed windowNoMuy bajoRedisTechos de tasa simples
Leaky bucketSí (suavizado)BajoDifícilShaping de tráfico

Lo que funciona

  • Retorna 429 con Retry-After: Esto ayuda a clientes bien comportados a retroceder automáticamente.
  • Usa diferentes límites por endpoint: los endpoints de autenticación deberían ser más estrictos (5 intentos/minuto) que los endpoints de datos de solo lectura (100 peticiones/minuto). Adapta los límites al costo y sensibilidad de cada operación.
  • Identifica clientes correctamente: limita por ID de usuario autenticado cuando esté disponible, no solo por dirección IP. Los NATs compartidos y VPNs pueden causar falsos positivos al limitar solo por IP.
  • Implementa límites por tier: los usuarios gratuitos obtienen 100 peticiones/hora, los pagados 10,000.
  • Monitorea peticiones rechazadas: un pico repentino en respuestas 429 puede indicar un ataque o un bug de cliente.

Errores comunes

  • No manejar skew de reloj: los sistemas distribuidos con desviación de reloj pueden calcular mal los límites de ventana.
  • Rate limiting solo en el edge: los gateways edge capturan la mayoría de abusos, pero un servicio interno comprometido puede aún saturar bases de datos downstream. Aplica límites en múltiples capas.
  • Bloquear usuarios legítimos después de una ráfaga: un usuario que legítimamente dispara una ráfaga (por ejemplo, paginando resultados) no debería ser bloqueado permanentemente.
  • Olvidar limpiar claves Redis: en implementaciones de sliding window, los timestamps antiguos se acumulan indefinidamente. Establece TTLs en claves Redis para auto-expirar datos obsoletos.

Soluciones Avanzadas

Token bucket atómico con Redis Lua

El ejemplo de token bucket en Python de arriba tiene una race condition: entre la lectura y escritura de tokens, otra petición puede modificar la misma clave. Usa un script Lua para atomicidad:

-- token_bucket.lua
-- KEYS[1] = rate limit key
-- ARGV[1] = capacity
-- ARGV[2] = refill_rate (tokens per second)
-- ARGV[3] = current timestamp (unix)
-- ARGV[4] = TTL in seconds

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * refill_rate)

local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end

redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, ttl)

return allowed
import time
import redis

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

# Cargar el script Lua una vez al inicio
TOKEN_BUCKET_SCRIPT = r.register_script(open('token_bucket.lua').read())

def is_allowed_atomic(key: str, capacity: int, refill_rate: float) -> bool:
    now = time.time()
    result = TOKEN_BUCKET_SCRIPT(
        keys=[key],
        args=[capacity, refill_rate, now, 60]
    )
    return bool(result)

# Uso — thread-safe a través de múltiples workers
if not is_allowed_atomic('user:123', capacity=100, refill_rate=10):
    return Response(status=429, headers={'Retry-After': '1'},
                    body=b"Rate limit exceeded")

Express con rate-limiter-flexible (Node.js)

El paquete rate-limiter-flexible soporta limitación distribuida respaldada por Redis con manejo de bursts integrado:

const { RateLimiterRedis } = require('rate-limiter-flexible');
const redis = require('redis');

const redisClient = redis.createClient({
  url: process.env.REDIS_URL,
});

// API general: 100 peticiones por 10 segundos
const apiLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'api',
  points: 100,
  duration: 10,
  blockDuration: 60, // Bloquear por 60s después de exceder
});

// Endpoints de auth: más estricto, 5 intentos por 60 segundos
const authLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'auth',
  points: 5,
  duration: 60,
  blockDuration: 300, // Bloquear por 5 minutos después de exceder
});

// Factory de middleware
function createLimiter(limiter, keyFn = (req) => req.ip) {
  return async (req, res, next) => {
    try {
      await limiter.consume(keyFn(req), 1);
      next();
    } catch (rejRes) {
      const retryAfter = Math.ceil(rejRes.msBeforeNext / 1000);
      res.set('Retry-After', String(retryAfter));
      res.set('X-RateLimit-Limit', String(limiter.points));
      res.set('X-RateLimit-Remaining', String(rejRes.remainingPoints || 0));
      res.set('X-RateLimit-Reset', new Date(Date.now() + rejRes.msBeforeNext).toISOString());
      res.status(429).json({
        error: 'Too many requests',
        retryAfter,
      });
    }
  };
}

// Aplicar diferentes limiters a diferentes rutas
app.use('/api/', createLimiter(apiLimiter));
app.use('/api/auth/', createLimiter(authLimiter, (req) => req.ip + ':' + (req.body?.email || '')));

Rate limiting en Spring Boot (Java)

import io.github.bucket4j.*;
import io.github.bucket4j.redis.*;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.*;

import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                              Object handler) throws Exception {
        String clientId = request.getHeader("X-User-ID");
        if (clientId == null) {
            clientId = request.getRemoteAddr();
        }

        Bucket bucket = buckets.computeIfAbsent(clientId, this::createBucket);

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            response.setHeader("X-RateLimit-Remaining",
                String.valueOf(probe.getRemainingTokens()));
            return true;
        }

        long retryAfter = probe.getNanosToWaitForRefill() / 1_000_000_000;
        response.setHeader("Retry-After", String.valueOf(retryAfter));
        response.setStatus(429);
        response.getWriter().write("Rate limit exceeded");
        return false;
    }

    private Bucket createBucket(String key) {
        // 100 peticiones por minuto con burst de 20
        Bandwidth limit = Bandwidth.builder()
            .capacity(100)
            .refillIntervally(100, Duration.ofMinutes(1))
            .build();

        return Bucket.builder()
            .addLimit(limit)
            .build();
    }
}

Rate limiting por tiers en Nginx

# Definir zones para diferentes tiers
limit_req_zone $binary_remote_addr zone=free:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=paid:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;

server {
  # Endpoints de auth — más estricto
  location /api/auth/ {
    limit_req zone=auth burst=3 nodelay;
    limit_req_status 429;
    limit_req_log_level warn;
    proxy_pass http://backend;
  }

  # Tier gratuito — identificado por API key header
  location /api/free/ {
    limit_req zone=free burst=20 nodelay;
    limit_req_status 429;
    add_header X-RateLimit-Tier "free" always;
    proxy_pass http://backend;
  }

  # Tier pagado — límites más altos
  location /api/paid/ {
    limit_req zone=paid burst=50 nodelay;
    limit_req_status 429;
    add_header X-RateLimit-Tier "paid" always;
    proxy_pass http://backend;
  }

  # Retornar Retry-After en 429
  error_page 429 = @ratelimited;
  location @ratelimited {
    add_header Retry-After "60" always;
    return 429 '{"error": "Too many requests", "retryAfter": 60}';
  }
}

Preguntas frecuentes

¿Esta solución está lista para producción?

Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.

¿Cuáles son las características de rendimiento?

El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.

¿Cómo depuro problemas con este enfoque?

Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.