Implementación de control de tráfico y limitación de peticiones en APIs PHP

Un escenario clásico donde esto resulta crítico es en arquitecturas basadas en PHP-FPM. Cada proceso worker disponible tiene un límite máximo, y cuando las peticiones concurrentes superan ese número, el pool de procesos se satura. Las solicitudes adicionales quedan encoladas y eventualmente Nginx devuelve un error 502 Bad Gateway.

Limitación en la capa de proxy inverso

Control de tráfico con Nginx

Nginx incorpora el módulo HttpLimitReqModule, que permite restringir la frecuencia de peticiones por dirección IP utilizando el algoritmo de cubeta con fugas (leaky bucket). La configuración se define en dos partes: primero se declara una zona de memoria compartida que almacena el estado de las peticiones, y luego se aplica la restricción en la ruta correspondiente.

http {
    # Zona compartida de 10MB con límite de 1 petición por segundo
    limit_req_zone $binary_remote_addr zone=api_zone:10m rate=1r/s;

    server {
        location /api/ {
            # Permite ráfagas de hasta 5 peticiones sin delay
            limit_req zone=api_zone burst=5 nodelay;
        }
    }
}

Algoritmos de limitación

Existen dos algoritmos principales que sustentan la mayoría de implementaciones de rate limiting: el algoritmo de cubeta con fugas y el algoritmo de cubeta de tokens.

Algoritmo de cubeta con fugas (Leaky Bucket)

Este algoritmo modela un contenedor con capacidad limitada que recibe agua (peticiones) a una tasa variable pero la libera a una tasa constante. Cuando el volumen entrante supera al saliente durante un período, el nivel del agua aumenta progresivamente. Si el agua supera la capacidad máxima del contenedor, las peticiones adicionales se rechazan.

La lógica de cálculo es la siguiente:

  • Volumen actual = volumen previo - volumen drenado + volumen entrante
  • Volumen drenado = (tiempo actual - tiempo previo) × tasa de drenado
  • Si el volumen actual excede la capacidad máxima, se rechaza la petición

Implementación en PHP con Redis

A continuación se presenta una clase que materializa el algoritmo de cubeta con fugas utilizando Redis como almacén de estado:

class LeakyBucketThrottle
{
    private int $maxVolume = 60;
    private int $incomingVolume = 20;
    private float $drainRate = 2.0;
    private string $bucketStateKey = 'leaky_bucket_state';
    private \Redis $cache;

    public function __construct()
    {
        $this->cache = new \Redis();
        $this->cache->connect('127.0.0.1', 6379);
    }

    /**
     * Verifica si una petición puede ser procesada
     * @param int $volume Volumen entrante de la petición
     * @param string $endpoint Ruta de la API a limitar
     * @return bool true si se permite, false si se rechaza
     */
    public function checkRequest(int $volume, string $endpoint = ''): bool
    {
        $this->incomingVolume = $volume;
        $stateKey = $endpoint ? $this->bucketStateKey . '_' . $endpoint : $this->bucketStateKey;

        [$currentVolume, $lastTimestamp, $now] = $this->retrieveState($stateKey);

        $elapsed = $now - $lastTimestamp;
        $drainedAmount = $elapsed * $this->drainRate;
        $currentVolume = max(0, $currentVolume - $drainedAmount);

        if (($currentVolume + $this->incomingVolume) <= $this->maxVolume) {
            $currentVolume += $this->incomingVolume;
            $this->persistState($stateKey, $currentVolume, $now);
            return true;
        }

        $this->persistState($stateKey, $currentVolume, $now);
        return false;
    }

    /**
     * Recupera el estado persistido del bucket
     * @return array [volumen actual, timestamp previo, timestamp actual]
     */
    private function retrieveState(string $key): array
    {
        $raw = $this->cache->get($key);
        $now = time();

        if ($raw) {
            $decoded = json_decode($raw, true);
            return [
                $decoded['volume'] ?? 0,
                $decoded['timestamp'] ?? $now,
                $now
            ];
        }

        $this->cache->set($key, json_encode([
            'volume' => 0,
            'timestamp' => $now
        ]));

        return [0, $now, $now];
    }

    /**
     * Persiste el estado del bucket
     */
    private function persistState(string $key, int $volume, int $timestamp): void
    {
        $this->cache->set($key, json_encode([
            'volume' => $volume,
            'timestamp' => $timestamp
        ]));
    }
}

Para validar el comportamiento, se puede simular un flujo de peticiones usando un bucle con pausas controladas. Con una tasa de drenado de 2 unidades por segundo y un volumen entrante de 10 por petición, las solicitudes espaciadas cada 2 segundos se procesan correctamente, mientras que peticiones más frecuentes agotan la capacidad del bucket alrededor de la cuarta iteración:

require_once 'LeakyBucketThrottle.php';

$throttle = new LeakyBucketThrottle();

for ($iteration = 1; $iteration <= 100; $iteration++) {
    sleep(1);
    $allowed = $throttle->checkRequest(10);
    var_dump($allowed);
}

Algoritmo de cubeta de tokens (Token Bucket)

Este enfoque opera de manera inversa al anterior. El sistema genera tokens a una velocidad configurada y los deposita en un contenedor con capacidad finita. Cada petición consumen un token. Cuando no quedan tokens disponibles, la petición se rechaza. Su principal ventaja es la flexibilidad para ajustar dinámicamente la tasa de generación de tokens, lo que permite adaptarse a diferentes patrones de tráfico. Frameworks como Hyperf ofrecen implementaciones basadas en este algoritmo.

Limitación en Laravel

Laravel proporciona un middleware integrado llamado throttle que aplica rate limiting de forma declarativa. La configuración se realiza en app/Http/Kernel.php:

protected $middlewareGroups = [
    'api' => [
        'throttle:60,1', // 60 peticiones por minuto
    ],
];

Análisis del funcionamiento interno

El middleware ThrottleRequests recibe una instancia de RateLimiter mediante inyección de dependencias. Esta clase encapsula la lógica de conteo y verificación utilizando el sistema de caché configurado en la aplicación.

class ThrottleRequests
{
    protected RateLimiter $limiter;

    public function __construct(RateLimiter $limiter)
    {
        $this->limiter = $limiter;
    }

    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
    {
        // Verifica si existe un limiter con nombre personalizado
        if (is_string($maxAttempts)
            && func_num_args() === 3
            && !is_null($customLimiter = $this->limiter->limiter($maxAttempts))) {
            return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $customLimiter);
        }

        return $this->handleRequest(
            $request,
            $next,
            [
                (object) [
                    'key' => $prefix . $this->resolveRequestSignature($request),
                    'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts),
                    'decayMinutes' => $decayMinutes,
                    'responseCallback' => null,
                ],
            ]
        );
    }
}

El método handleRequest itera sobre cada límite definido y verifica si se ha alcanzado el umbral. Si no se ha superado, incrementa el contador de peticiones mediante hit(), que internamente realiza un incremento atómico en el almacén de caché con un tiempo de expiración. Finalmente, añade cabeceras HTTP informativas sobre los intentos restantes:

protected function handleRequest($request, Closure $next, array $limits)
{
    foreach ($limits as $limit) {
        if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) {
            throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback);
        }
        $this->limiter->hit($limit->key, $limit->decayMinutes * 60);
    }

    $response = $next($request);

    foreach ($limits as $limit) {
        $response = $this->addHeaders(
            $response,
            $limit->maxAttempts,
            $this->calculateRemainingAttempts($limit->key, $limit->maxAttempts)
        );
    }

    return $response;
}

El método tooManyAttempts del RateLimiter compara el contador actual contra el máximo permitido. Si el contador existe y el temporizador sigue activo, la petición se bloquea. Si el temporizador ya expiró, se reinicia el contadro:

public function tooManyAttempts($key, $maxAttempts)
{
    if ($this->attempts($key) >= $maxAttempts) {
        if ($this->cache->has($key . ':timer')) {
            return true;
        }
        $this->resetAttempts($key);
    }
    return false;
}

Implementación personalizada de limitación por ventana

Inspirado en la lógica anterior, se puede construir una clase que aplique limitación basada en ventana de tiempo fija utilizando Redis como backend:

class FixedWindowRateLimiter
{
    private \Redis $cache;
    private string $identifier;
    private int $maxRequests;
    private int $windowSeconds;

    public function __construct(string $endpoint, string $clientIp, int $threshold, int $windowMinutes)
    {
        $this->cache = new \Redis();
        $this->cache->connect('127.0.0.1', 6379, 3);
        $this->identifier = $clientIp . ':' . $endpoint;
        $this->maxRequests = $threshold;
        $this->windowSeconds = $windowMinutes * 60;
    }

    /**
     * Obtiene el contador actual de peticiones
     */
    private function getCurrentCount(): int
    {
        $count = $this->cache->get($this->identifier);
        return $count === false ? 0 : (int)$count;
    }

    /**
     * Determina si la petición actual puede proceder
     */
    public function allowRequest(): bool
    {
        $count = $this->getCurrentCount();

        if ($count >= $this->maxRequests) {
            return false;
        }

        if ($count === 0) {
            $this->cache->set($this->identifier, 0, $this->windowSeconds);
        }

        // Manejo de concurrencia con transacción optimista
        $this->cache->watch($this->identifier);
        $this->cache->multi();
        $this->cache->incr($this->identifier);
        $result = $this->cache->exec();

        return $result !== false;
    }
}

Protección contra ataques DDoS

Los atacantes suelen emplear múltiples IPs distribuidas para evadir restricciones basadas en una sola dirección. En estos escenarios, la limitación en la capa de proxy debe combinarse con un mecanismo de listas negras dinámicas. La estrategia consiste en monitorear la frecuencia de peticiones por IP y, al superar un umbral sospechoso dentro de una ventana de tiempo, bloquear automáticamente esa IP durante un período determinado.

Etiquetas: rate-limiting Nginx Redis Laravel PHP

Publicado el 7-4 05:01