apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); } public function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array { return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); } public function isBlocked(string $scope, string $subject): array { $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return ['allowed' => true, 'retry_after' => 0]; } try { $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); if (!is_array($row)) { return ['allowed' => true, 'retry_after' => 0]; } $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? '')); if ($blockedUntilTs === null) { return ['allowed' => true, 'retry_after' => 0]; } $nowTs = time(); if ($blockedUntilTs <= $nowTs) { $this->rateLimitRepository->updateStateById( (int) ($row['id'] ?? 0), 0, gmdate('Y-m-d H:i:s', $nowTs), null ); return ['allowed' => true, 'retry_after' => 0]; } return [ 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; } catch (\Throwable) { return $this->handleReadStorageFailure($normalized['scope'], $normalized['subject_hash']); } } public function reset(string $scope, string $subject): void { $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return; } try { $this->rateLimitRepository->deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']); } catch (\Throwable $exception) { // Ignore reset failures. } } private function apply( string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds, bool $increment ): array { $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return ['allowed' => true, 'retry_after' => 0]; } $maxHits = max(1, $maxHits); $windowSeconds = max(1, $windowSeconds); $blockSeconds = max(1, $blockSeconds); try { $nowTs = time(); $nowSql = gmdate('Y-m-d H:i:s', $nowTs); // Two-attempt loop handles a creation race: if two requests arrive simultaneously for a // new subject, both may find no row and try to INSERT. The second INSERT fails (unique key), // so we retry once and the second attempt reads the now-existing row. for ($attempt = 0; $attempt < 2; $attempt++) { $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); if (!is_array($row)) { if (!$increment) { return ['allowed' => true, 'retry_after' => 0]; } $hits = 1; $created = $this->rateLimitRepository->create( $normalized['scope'], $normalized['subject_hash'], $hits, $nowSql, null ); if ($created) { return ['allowed' => true, 'retry_after' => 0]; } continue; } $id = (int) ($row['id'] ?? 0); if ($id <= 0) { return ['allowed' => true, 'retry_after' => 0]; } $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? '')); if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) { return [ 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; } $windowStartedTs = $this->parseTimestamp((string) ($row['window_started_at'] ?? '')); // Sliding window expired — reset the window start and hit count. if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) { $windowStartedTs = $nowTs; $hits = 0; } else { $hits = max(0, (int) ($row['hits'] ?? 0)); } if ($increment) { $hits++; } $newBlockedUntilTs = null; if ($hits > $maxHits) { $newBlockedUntilTs = $nowTs + $blockSeconds; } $this->rateLimitRepository->updateStateById( $id, $hits, gmdate('Y-m-d H:i:s', $windowStartedTs), $newBlockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $newBlockedUntilTs) : null ); if ($newBlockedUntilTs !== null) { return [ 'allowed' => false, 'retry_after' => max(1, $newBlockedUntilTs - $nowTs), ]; } return ['allowed' => true, 'retry_after' => 0]; } return $this->handleApplyStorageFailure( $normalized['scope'], $normalized['subject_hash'], $maxHits, $windowSeconds, $blockSeconds, $increment ); } catch (\Throwable) { return $this->handleApplyStorageFailure( $normalized['scope'], $normalized['subject_hash'], $maxHits, $windowSeconds, $blockSeconds, $increment ); } } private function handleReadStorageFailure(string $scope, string $subjectHash): array { if ($this->fallbackGateway !== null) { try { return $this->fallbackIsBlocked($scope, $subjectHash); } catch (\Throwable) { // Fall through to policy fallback. } } return $this->storageFailurePolicy($scope); } private function handleApplyStorageFailure( string $scope, string $subjectHash, int $maxHits, int $windowSeconds, int $blockSeconds, bool $increment ): array { if ($this->fallbackGateway !== null) { try { return $this->fallbackApply($scope, $subjectHash, $maxHits, $windowSeconds, $blockSeconds, $increment); } catch (\Throwable) { // Fall through to policy fallback. } } return $this->storageFailurePolicy($scope); } private function fallbackIsBlocked(string $scope, string $subjectHash): array { $state = $this->fallbackGateway->load($scope, $subjectHash); if (!is_array($state)) { return ['allowed' => true, 'retry_after' => 0]; } $blockedUntilTs = max(0, (int) ($state['blocked_until'] ?? 0)); if ($blockedUntilTs <= 0) { return ['allowed' => true, 'retry_after' => 0]; } $nowTs = time(); if ($blockedUntilTs <= $nowTs) { $this->fallbackGateway->remove($scope, $subjectHash); return ['allowed' => true, 'retry_after' => 0]; } return [ 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; } private function fallbackApply( string $scope, string $subjectHash, int $maxHits, int $windowSeconds, int $blockSeconds, bool $increment ): array { $nowTs = time(); $state = $this->fallbackGateway->load($scope, $subjectHash); $hits = 0; $windowStartedTs = $nowTs; $blockedUntilTs = 0; if (is_array($state)) { $blockedUntilTs = max(0, (int) ($state['blocked_until'] ?? 0)); if ($blockedUntilTs > $nowTs) { return [ 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; } $windowStartedTs = max(0, (int) ($state['window_started_at'] ?? 0)); $hits = max(0, (int) ($state['hits'] ?? 0)); if ($windowStartedTs <= 0 || ($windowStartedTs + $windowSeconds) <= $nowTs) { $windowStartedTs = $nowTs; $hits = 0; } } elseif (!$increment) { return ['allowed' => true, 'retry_after' => 0]; } if ($increment) { $hits++; } $blockedUntilTs = 0; if ($hits > $maxHits) { $blockedUntilTs = $nowTs + $blockSeconds; } $expiresAtTs = $blockedUntilTs > 0 ? $blockedUntilTs : ($windowStartedTs + $windowSeconds); $ttlSeconds = max(1, ($expiresAtTs - $nowTs) + self::FALLBACK_TTL_BUFFER_SECONDS); $this->fallbackGateway->save($scope, $subjectHash, [ 'hits' => $hits, 'window_started_at' => $windowStartedTs, 'blocked_until' => $blockedUntilTs > 0 ? $blockedUntilTs : null, ], $ttlSeconds); if ($blockedUntilTs > 0) { return [ 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; } return ['allowed' => true, 'retry_after' => 0]; } private function storageFailurePolicy(string $scope): array { if ($this->isFailSecureScope($scope)) { return [ 'allowed' => false, 'retry_after' => self::STORAGE_DEGRADED_RETRY_AFTER_SECONDS, ]; } return ['allowed' => true, 'retry_after' => 0]; } private function isFailSecureScope(string $scope): bool { foreach (self::FAIL_SECURE_SCOPE_PREFIXES as $prefix) { if (str_starts_with($scope, $prefix)) { return true; } } return false; } private function normalize(string $scope, string $subject): ?array { $scope = strtolower(trim($scope)); $subject = trim($subject); if ($scope === '' || $subject === '') { return null; } if (strlen($scope) > 64) { $scope = substr($scope, 0, 64); } // Hash the subject (e.g. email or IP) so PII is never stored in the rate limit table. return [ 'scope' => $scope, 'subject_hash' => hash(self::HASH_ALGO, $subject), ]; } private function parseTimestamp(string $value): ?int { $value = trim($value); if ($value === '') { return null; } $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC')); if ($date instanceof DateTimeImmutable) { return $date->getTimestamp(); } $timestamp = strtotime($value . ' UTC'); if ($timestamp === false || $timestamp <= 0) { return null; } return $timestamp; } }