['limit' => 10, 'window' => 3600], 'steps' => ['limit' => 60, 'window' => 60], 'uploads' => ['limit' => 10, 'window' => 600], ]; } function raijuAgentRateLimitDir(): string { return defined('RAIJU_AGENT_RATE_DIR') ? rtrim((string)RAIJU_AGENT_RATE_DIR, '/') : dirname(__DIR__, 3) . '/content/ai-agent/rate'; } /** Stable, filesystem-safe file name for a user key; odd keys are hashed. */ function raijuAgentRateLimitFile(string $userKey): string { $name = preg_match('/^[A-Za-z0-9_-]{1,64}$/', $userKey) ? $userKey : 'h_' . substr(hash('sha256', $userKey), 0, 40); return raijuAgentRateLimitDir() . '/' . $name . '.json'; } /** * Count one hit (or only look, with $consume = false) against a bucket. If the * state cannot be read or written the call is denied (fail closed). * * @param array|null $config ['limit' => int, 'window' => seconds]; null = default for the bucket * @return array{allowed:bool,limit:int,remaining:int,retryAfter:int} */ function raijuAgentRateLimit(string $userKey, string $bucket, ?array $config = null, bool $consume = true, ?int $now = null): array { $config = $config ?? (raijuAgentRateLimitDefaults()[$bucket] ?? null); if (!preg_match('/^[a-z][a-z0-9_]{0,31}$/', $bucket) || $config === null || trim($userKey) === '') { throw new InvalidArgumentException('Unknown rate limit bucket or empty user key.'); } $limit = max(1, (int)($config['limit'] ?? 1)); $window = max(1, (int)($config['window'] ?? 60)); $now = $now ?? time(); $result = ['allowed' => false, 'limit' => $limit, 'remaining' => 0, 'retryAfter' => $window]; $evaluated = false; $update = static function (array &$data) use ($bucket, $limit, $window, $now, $consume, &$result, &$evaluated): bool { $evaluated = true; $stored = is_array($data['buckets'][$bucket] ?? null) ? $data['buckets'][$bucket] : []; // Keep only hits inside the window; a timestamp from the far future is clock noise. $hits = array_values(array_filter($stored, static fn($time) => is_int($time) && $time > $now - $window && $time <= $now + 60)); sort($hits); if (count($hits) >= $limit) { $result['retryAfter'] = max(1, $hits[count($hits) - $limit] + $window - $now); $data['buckets'][$bucket] = $hits; return $hits !== $stored; // only persist the pruning } $result['allowed'] = true; if ($consume) { $hits[] = $now; } $result['remaining'] = max(0, $limit - count($hits)); $result['retryAfter'] = 0; $data['buckets'][$bucket] = $hits; return $consume; }; $file = raijuAgentRateLimitFile($userKey); raijuJsonUpdate($file, $update, ['buckets' => []]); if (!$evaluated && is_file($file) && !is_array(json_decode((string)@file_get_contents($file), true))) { // A damaged state file would lock the user out for good, and rate data is // disposable: set it aside once and start over. @rename($file, $file . '.corrupt'); raijuJsonUpdate($file, $update, ['buckets' => []]); } return $evaluated ? $result : ['allowed' => false, 'limit' => $limit, 'remaining' => 0, 'retryAfter' => $window]; } /** Like raijuAgentRateLimit(), but throws a RuntimeException (code 429) when the limit is hit. */ function raijuAgentAssertRateLimit(string $userKey, string $bucket, ?array $config = null): array { $result = raijuAgentRateLimit($userKey, $bucket, $config); if (!$result['allowed']) { throw new RuntimeException('Rate limit reached. Try again in ' . $result['retryAfter'] . ' seconds.', 429); } return $result; }