Suas ideias em realidade digital!
Rate limit é uma proteção que limita o número de requisições em um intervalo de tempo. Sem isso, sua API fica vulnerável a:
Neste guia você vai implementar rate limit por rota + por IP, com duas opções:
ip:rota)Retry-After (boa prática)| Rota | Limite | Motivo |
|---|---|---|
| /api/auth/login | 5 por minuto | anti brute force |
| /api/auth/refresh | 20 por hora | evita abuso |
| /api/posts | 60 por minuto | uso normal |
| /api/tools/* | 120 por minuto | picos aceitáveis |
Com Redis você consegue performance alta e controle simples. A lógica mais usada é fixed window:
rl:{rota}:{ip}:{janela}
ex:
rl:/api/auth/login:189.10.10.10:20260212-1430
<?php
function rateLimitRedis($redis, string $routeKey, string $ip, int $max, int $windowSeconds): array {
$windowId = (int)(time() / $windowSeconds);
$key = "rl:{$routeKey}:{$ip}:{$windowId}";
$current = $redis->incr($key);
if ($current === 1) {
$redis->expire($key, $windowSeconds);
}
$ttl = $redis->ttl($key);
$remaining = max(0, $max - $current);
$allowed = ($current <= $max);
return [
'allowed' => $allowed,
'remaining' => $remaining,
'retry_after' => $allowed ? 0 : max(1, $ttl),
];
}
<?php
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '/';
$routeKey = $path; // você pode normalizar
// limites por rota
$rules = [
'/api/auth/login' => ['max' => 5, 'window' => 60],
'/api/auth/refresh' => ['max' => 20, 'window' => 3600],
'/api/posts' => ['max' => 60, 'window' => 60],
];
$rule = $rules[$routeKey] ?? ['max' => 120, 'window' => 60];
// $redis = new Redis(); ... connect ...
$r = rateLimitRedis($redis, $routeKey, $ip, $rule['max'], $rule['window']);
header("X-RateLimit-Remaining: {$r['remaining']}");
if (!$r['allowed']) {
http_response_code(429);
header("Retry-After: {$r['retry_after']}");
echo json_encode(['error' => 'Too Many Requests', 'retry_after' => $r['retry_after']]);
exit;
}
Se você ainda não usa Redis, dá pra fazer rate limit com MySQL. É menos performático, mas funciona bem para volumes pequenos/médios.
CREATE TABLE api_rate_limits (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
route_key VARCHAR(190) NOT NULL,
ip VARCHAR(64) NOT NULL,
window_id BIGINT NOT NULL,
hits INT NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_route_ip_window (route_key, ip, window_id),
INDEX (expires_at)
);
<?php
function rateLimitMySQL(PDO $pdo, string $routeKey, string $ip, int $max, int $windowSeconds): array {
$windowId = (int)(time() / $windowSeconds);
$expiresAt = (new DateTimeImmutable())->modify("+{$windowSeconds} seconds")->format('Y-m-d H:i:s');
// incrementa/insere
$stmt = $pdo->prepare("
INSERT INTO api_rate_limits (route_key, ip, window_id, hits, expires_at)
VALUES (:r, :ip, :w, 1, :exp)
ON DUPLICATE KEY UPDATE hits = hits + 1
");
$stmt->execute([
':r' => $routeKey,
':ip' => $ip,
':w' => $windowId,
':exp' => $expiresAt,
]);
// lê hits
$stmt = $pdo->prepare("SELECT hits, expires_at FROM api_rate_limits WHERE route_key=:r AND ip=:ip AND window_id=:w LIMIT 1");
$stmt->execute([':r' => $routeKey, ':ip' => $ip, ':w' => $windowId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$hits = (int)($row['hits'] ?? 0);
$ttl = max(1, strtotime($row['expires_at'] ?? $expiresAt) - time());
$remaining = max(0, $max - $hits);
$allowed = ($hits <= $max);
return [
'allowed' => $allowed,
'remaining' => $remaining,
'retry_after' => $allowed ? 0 : $ttl,
];
}
<?php
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$routeKey = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '/';
$rules = [
'/api/auth/login' => ['max' => 5, 'window' => 60],
'/api/auth/refresh' => ['max' => 20, 'window' => 3600],
];
$rule = $rules[$routeKey] ?? ['max' => 120, 'window' => 60];
$r = rateLimitMySQL($pdo, $routeKey, $ip, $rule['max'], $rule['window']);
header("X-RateLimit-Remaining: {$r['remaining']}");
if (!$r['allowed']) {
http_response_code(429);
header("Retry-After: {$r['retry_after']}");
echo json_encode(['error' => 'Too Many Requests', 'retry_after' => $r['retry_after']]);
exit;
}
Para evitar crescer sem controle, limpe registros expirados:
DELETE FROM api_rate_limits WHERE expires_at < NOW();
Você pode rodar isso em cron 1x por dia ou a cada hora.
Retry-After