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.
