Makrosites

Suas ideias em realidade digital!

Rate limit em APIs PHP: por rota + por IP (com Redis opcional)

Rate limit em APIs PHP: por rota + por IP (com Redis opcional)

O que é Rate Limit e por que sua API precisa disso?

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:


Estratégia recomendada

Exemplo de limites por rota (sugestão)

RotaLimiteMotivo
/api/auth/login5 por minutoanti brute force
/api/auth/refresh20 por horaevita abuso
/api/posts60 por minutouso normal
/api/tools/*120 por minutopicos aceitáveis

1) Rate limit com Redis (opcional, recomendado)

Com Redis você consegue performance alta e controle simples. A lógica mais usada é fixed window:

Chave sugerida

rl:{rota}:{ip}:{janela}
ex:
rl:/api/auth/login:189.10.10.10:20260212-1430

Código (Redis)

<?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),
  ];
}

Como aplicar por rota

<?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;
}

2) Fallback em MySQL (sem Redis)

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.

Tabela

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)
);

Implementação MySQL

<?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,
  ];
}

Aplicação no request

<?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;
}

3) Limpeza (MySQL)

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.


Boas práticas (produção)