Rate limiting en PHP: algoritmos y contadores atómicos con APCu

El rate limiting (limitación de peticiones) protege tu API de abusos, ataques de fuerza bruta y picos de tráfico involuntarios. Consiste en contar cuántas peticiones hace un cliente en un período de tiempo y devolver HTTP 429 Too Many Requests cuando supera el límite. En PHP hay tres algoritmos principales y varias formas de implementarlos.

Algoritmo 1: Fixed Window Counter con APCu

El más sencillo: cuenta las peticiones en una ventana de tiempo fija (por ejemplo, 100 peticiones por minuto). APCu es una caché en memoria compartida entre los workers de PHP-FPM, perfecta para rate limiting en un único servidor.

<?php
function rateLimitFijo(string $ip, int $limite = 100, int $ventanaSegundos = 60): void {
    $clave = "rl:{$ip}:" . floor(time() / $ventanaSegundos);

    // apcu_add devuelve false si la clave ya existe ? contador ya creado
    apcu_add($clave, 0, $ventanaSegundos + 1);

    // apcu_inc es atómico: incrementa y devuelve el nuevo valor
    $peticiones = apcu_inc($clave);

    header('X-RateLimit-Limit: ' . $limite);
    header('X-RateLimit-Remaining: ' . max(0, $limite - $peticiones));

    if ($peticiones > $limite) {
        header('Retry-After: ' . ($ventanaSegundos - (time() % $ventanaSegundos)));
        http_response_code(429);
        echo json_encode(['error' => 'Demasiadas peticiones. Inténtalo más tarde.']);
        exit;
    }
}

// Uso al inicio del controlador
rateLimitFijo($_SERVER['REMOTE_ADDR'], limite: 60, ventanaSegundos: 60);

El único defecto del Fixed Window es el «burst» al borde de la ventana: un cliente puede hacer 100 peticiones al final del minuto 1 y otras 100 al inicio del minuto 2, acumulando 200 en unos segundos.

Algoritmo 2: Sliding Window Log con Redis

Guarda el timestamp de cada petición en un sorted set de Redis. Antes de permitir la petición, elimina las entradas más antiguas que la ventana y cuenta las restantes:

<?php
function rateLimitVentanaDeslizante(
    Redis $redis,
    string $ip,
    int    $limite = 100,
    int    $ventanaMs = 60000   // 60 segundos en milisegundos
): void {
    $clave  = "rl:sw:{$ip}";
    $ahoraMs = (int)(microtime(true) * 1000);
    $desdeMs = $ahoraMs - $ventanaMs;

    $redis->multi();
    $redis->zRemRangeByScore($clave, '-inf', (string)$desdeMs);  // eliminar viejas
    $redis->zAdd($clave, $ahoraMs, (string)$ahoraMs);            // añadir la actual
    $redis->zCard($clave);                                         // contar
    $redis->expire($clave, (int)ceil($ventanaMs / 1000) + 1);
    [$eliminadas, $agregadas, $total, $exp] = $redis->exec();

    if ($total > $limite) {
        header('Retry-After: ' . ceil($ventanaMs / 1000));
        http_response_code(429);
        echo json_encode(['error' => 'Rate limit superado']);
        exit;
    }

    header('X-RateLimit-Limit: '     . $limite);
    header('X-RateLimit-Remaining: ' . max(0, $limite - $total));
}

Algoritmo 3: Token Bucket con Redis

Cada cliente tiene un «cubo» de tokens que se rellena a una tasa fija. Cada petición consume un token; si el cubo está vacío, se devuelve 429. Permite bursts controlados:

<?php
function rateLimitTokenBucket(
    Redis $redis,
    string $ip,
    int    $capacidad = 20,   // máximo tokens simultáneos
    float  $tasaRecarga = 5   // tokens por segundo
): void {
    $clave  = "rl:tb:{$ip}";
    $ahora  = microtime(true);

    $redis->watch($clave);
    $datos  = $redis->hGetAll($clave);

    $tokens = isset($datos['tokens']) ? (float)$datos['tokens'] : $capacidad;
    $ultimo = isset($datos['ultimo']) ? (float)$datos['ultimo'] : $ahora;

    // Recargar tokens según el tiempo transcurrido
    $recargados = ($ahora - $ultimo) * $tasaRecarga;
    $tokens     = min($capacidad, $tokens + $recargados);

    if ($tokens < 1) {
        $redis->unwatch();
        $espera = (1 - $tokens) / $tasaRecarga;
        header('Retry-After: ' . ceil($espera));
        http_response_code(429);
        echo json_encode(['error' => 'Rate limit superado']);
        exit;
    }

    $tokens -= 1;

    $redis->multi();
    $redis->hMSet($clave, ['tokens' => $tokens, 'ultimo' => $ahora]);
    $redis->expire($clave, (int)ceil($capacidad / $tasaRecarga) + 60);
    $redis->exec();

    header('X-RateLimit-Remaining: ' . floor($tokens));
}

Patrón middleware y límites por ruta

<?php
class RateLimitMiddleware {
    public function __construct(
        private Redis $redis,
        private array  $limites = [
            '/api/login'      => ['limite' => 5,   'ventana' => 300],
            '/api/registro'   => ['limite' => 3,   'ventana' => 3600],
            'default'         => ['limite' => 120, 'ventana' => 60],
        ]
    ) {}

    public function handle(string $ruta, string $ip): void {
        $config   = $this->limites[$ruta] ?? $this->limites['default'];
        $clave    = "rl:{$ip}:" . floor(time() / $config['ventana']);

        apcu_add($clave, 0, $config['ventana'] + 1);
        $contador = apcu_inc($clave);

        if ($contador > $config['limite']) {
            http_response_code(429);
            header('Retry-After: ' . ($config['ventana'] - (time() % $config['ventana'])));
            echo json_encode(['error' => 'Demasiadas peticiones']);
            exit;
        }
    }
}

Evitar race conditions

Con APCu, apcu_inc() es atómico y no tiene race conditions. Con Redis, usa transacciones (MULTI/EXEC con WATCH) o comandos Lua para operaciones fetch+store atómicas. Un contador no atómico puede dejar pasar más peticiones de las permitidas bajo carga alta.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP