prepare( "INSERT INTO auth_throttle (bucket, key_hash, window_start, hits) VALUES (:b, :k, NOW(), 1) ON DUPLICATE KEY UPDATE hits = IF(window_start < NOW() - INTERVAL :w1 SECOND, 1, hits + 1), window_start = IF(window_start < NOW() - INTERVAL :w2 SECOND, NOW(), window_start)" )->execute([':b' => $bucket, ':k' => $key_hash, ':w1' => $window_seconds, ':w2' => $window_seconds]); $sth = $pdo->prepare("SELECT hits FROM auth_throttle WHERE bucket = :b AND key_hash = :k"); $sth->execute([':b' => $bucket, ':k' => $key_hash]); return (int)$sth->fetchColumn() > $max; } catch (Throwable $e) { error_log('[rate_limit] throttle check skipped (' . $bucket . '): ' . $e->getMessage()); return false; } } } if (!function_exists('rate_limit_guard')) { /** * Count every check and stop the request with HTTP 429 if any is over its * limit. Each check is [bucket, key, max, window_seconds]. */ function rate_limit_guard(PDO $pdo, array $checks): void { $limited = false; foreach ($checks as [$bucket, $key, $max, $window]) { if (rate_limit_hit($pdo, $bucket, (string)$key, (int)$max, (int)$window)) { $limited = true; } } if ($limited) { rate_limit_reject(); } } } if (!function_exists('rate_limit_reject')) { /** Answer 429 with the same generic message everywhere and stop. */ function rate_limit_reject(): void { http_response_code(429); header('Retry-After: 300'); exit(json_encode([ 'success' => 0, 'message' => 'Too many requests. Please wait a few minutes and try again.', 'code' => 'rate_limited', ])); } }