getCacheKey($key); $effectiveLimit = (int) floor($limit * $burst); $now = Carbon::now(); $windowStart = $now->timestamp - $window; // Get current window data $hits = $this->getWindowHits($cacheKey, $windowStart); $currentCount = count($hits); $remaining = max(0, $effectiveLimit - $currentCount); // Calculate reset time $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit); if ($currentCount >= $effectiveLimit) { // Find oldest hit to determine retry after $oldestHit = min($hits); $retryAfter = max(1, ($oldestHit + $window) - $now->timestamp); return RateLimitResult::denied($limit, $retryAfter, $resetsAt); } return RateLimitResult::allowed($limit, $remaining, $resetsAt); } /** * Record a hit and check if the request is allowed. * * @param string $key Unique identifier for the rate limit bucket * @param int $limit Maximum requests allowed * @param int $window Time window in seconds * @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance) */ public function hit(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult { $cacheKey = $this->getCacheKey($key); $effectiveLimit = (int) floor($limit * $burst); $now = Carbon::now(); $windowStart = $now->timestamp - $window; // Get current window data and clean up old entries $hits = $this->getWindowHits($cacheKey, $windowStart); $currentCount = count($hits); // Calculate reset time $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit); if ($currentCount >= $effectiveLimit) { // Find oldest hit to determine retry after $oldestHit = min($hits); $retryAfter = max(1, ($oldestHit + $window) - $now->timestamp); return RateLimitResult::denied($limit, $retryAfter, $resetsAt); } // Record the hit $hits[] = $now->timestamp; $this->storeWindowHits($cacheKey, $hits, $window); $remaining = max(0, $effectiveLimit - count($hits)); return RateLimitResult::allowed($limit, $remaining, $resetsAt); } /** * Get remaining attempts for a key. * * @param string $key Unique identifier for the rate limit bucket * @param int $limit Maximum requests allowed (needed to calculate remaining) * @param int $window Time window in seconds * @param float $burst Burst multiplier */ public function remaining(string $key, int $limit, int $window, float $burst = 1.0): int { $cacheKey = $this->getCacheKey($key); $effectiveLimit = (int) floor($limit * $burst); $windowStart = Carbon::now()->timestamp - $window; $hits = $this->getWindowHits($cacheKey, $windowStart); return max(0, $effectiveLimit - count($hits)); } /** * Reset (clear) a rate limit bucket. */ public function reset(string $key): void { $cacheKey = $this->getCacheKey($key); $this->cache->forget($cacheKey); } /** * Get the current hit count for a key. */ public function attempts(string $key, int $window): int { $cacheKey = $this->getCacheKey($key); $windowStart = Carbon::now()->timestamp - $window; return count($this->getWindowHits($cacheKey, $windowStart)); } /** * Build a rate limit key for an endpoint. */ public function buildEndpointKey(string $identifier, string $endpoint): string { return "endpoint:{$identifier}:{$endpoint}"; } /** * Build a rate limit key for a workspace. */ public function buildWorkspaceKey(int $workspaceId, ?string $suffix = null): string { $key = "workspace:{$workspaceId}"; if ($suffix !== null) { $key .= ":{$suffix}"; } return $key; } /** * Build a rate limit key for an API key. */ public function buildApiKeyKey(int|string $apiKeyId, ?string $suffix = null): string { $key = "api_key:{$apiKeyId}"; if ($suffix !== null) { $key .= ":{$suffix}"; } return $key; } /** * Build a rate limit key for an IP address. */ public function buildIpKey(string $ip, ?string $suffix = null): string { $key = "ip:{$ip}"; if ($suffix !== null) { $key .= ":{$suffix}"; } return $key; } /** * Get hits within the sliding window. * * @return array Array of timestamps */ protected function getWindowHits(string $cacheKey, int $windowStart): array { /** @var array $hits */ $hits = $this->cache->get($cacheKey, []); // Filter to only include hits within the window return array_values(array_filter($hits, fn (int $timestamp) => $timestamp >= $windowStart)); } /** * Store hits in cache. * * @param array $hits Array of timestamps */ protected function storeWindowHits(string $cacheKey, array $hits, int $window): void { // Add buffer to TTL to handle clock drift $ttl = $window + 60; $this->cache->put($cacheKey, $hits, $ttl); } /** * Calculate when the rate limit resets. * * @param array $hits Array of timestamps */ protected function calculateResetTime(array $hits, int $window, int $limit): Carbon { if (empty($hits)) { return Carbon::now()->addSeconds($window); } // If under limit, reset is at the end of the window if (count($hits) < $limit) { return Carbon::now()->addSeconds($window); } // If at or over limit, reset when the oldest hit expires $oldestHit = min($hits); return Carbon::createFromTimestamp($oldestHit + $window); } /** * Generate the cache key. */ protected function getCacheKey(string $key): string { return self::CACHE_PREFIX.$key; } }