-
-
Notifications
You must be signed in to change notification settings - Fork 143
feat(router): add rate limiting middleware #1947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Tresor-Kasenda
wants to merge
3
commits into
tempestphp:3.x
from
Tresor-Kasenda:feature/rate-limiting-middleware-clean
Closed
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tempest\Cache\RateLimiting; | ||
|
|
||
| use Tempest\Cache\Cache; | ||
| use Tempest\DateTime\DateTime; | ||
| use Tempest\DateTime\Duration; | ||
|
|
||
| /** | ||
| * A rate limiter implementation using the cache. | ||
| */ | ||
| final readonly class CacheRateLimiter implements RateLimiter | ||
| { | ||
| private const string PREFIX = 'tempest_rate_limit_'; | ||
|
|
||
| public function __construct( | ||
| private Cache $cache, | ||
| ) {} | ||
|
|
||
| public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateLimitResult | ||
| { | ||
| $cacheKey = $this->getCacheKey($key); | ||
| $timerKey = $this->getTimerKey($key); | ||
|
|
||
| // Get or set the timer (when the window ends) | ||
| $resetAt = (int) $this->cache->get($timerKey); | ||
|
|
||
| if ($resetAt === 0) { | ||
| $resetAt = time() + $decaySeconds; | ||
| $this->cache->put($timerKey, $resetAt, Duration::seconds($decaySeconds)); | ||
| } | ||
|
|
||
| // Increment the counter | ||
| $attempts = $this->cache->increment($cacheKey); | ||
|
|
||
| // Set expiration on first hit | ||
| if ($attempts === 1) { | ||
| $this->cache->put($cacheKey, 1, Duration::seconds($decaySeconds)); | ||
| } | ||
Tresor-Kasenda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
Tresor-Kasenda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| $remaining = max(0, $maxAttempts - $attempts); | ||
|
|
||
| if ($attempts > $maxAttempts) { | ||
| return RateLimitResult::deny($maxAttempts, (int) $resetAt); | ||
| } | ||
|
|
||
| return RateLimitResult::allow($maxAttempts, $remaining, $resetAt); | ||
| } | ||
|
|
||
| public function attempts(string $key): int | ||
| { | ||
| return (int) ($this->cache->get($this->getCacheKey($key)) ?? 0); | ||
| } | ||
|
|
||
| public function remaining(string $key, int $maxAttempts): int | ||
| { | ||
| return max(0, $maxAttempts - $this->attempts($key)); | ||
| } | ||
|
|
||
| public function tooManyAttempts(string $key, int $maxAttempts): bool | ||
| { | ||
| return $this->attempts($key) >= $maxAttempts; | ||
| } | ||
|
|
||
| public function clear(string $key): void | ||
| { | ||
| $this->cache->remove($this->getCacheKey($key)); | ||
| $this->cache->remove($this->getTimerKey($key)); | ||
| } | ||
|
|
||
| public function availableAt(string $key): DateTime | ||
| { | ||
| $timestamp = (int) ($this->cache->get($this->getTimerKey($key)) ?? time()); | ||
|
|
||
| return DateTime::fromTimestamp($timestamp); | ||
| } | ||
|
|
||
| private function getCacheKey(string $key): string | ||
| { | ||
| return self::PREFIX . $this->sanitizeKey($key); | ||
| } | ||
|
|
||
| private function getTimerKey(string $key): string | ||
| { | ||
| return self::PREFIX . $this->sanitizeKey($key) . '_timer'; | ||
| } | ||
|
|
||
| /** | ||
| * Sanitize a cache key to be compatible with all cache adapters. | ||
| * Replaces reserved characters with underscores. | ||
| */ | ||
| private function sanitizeKey(string $key): string | ||
| { | ||
| return preg_replace('/[{}()\/@:\\\\]/', '_', $key); | ||
Tresor-Kasenda marked this conversation as resolved.
Show resolved
Hide resolved
Tresor-Kasenda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tempest\Cache\RateLimiting; | ||
|
|
||
| /** | ||
| * The result of a rate limit check. | ||
| */ | ||
| final readonly class RateLimitResult | ||
| { | ||
| public function __construct( | ||
| /** Whether the request should be allowed. */ | ||
| public bool $allowed, | ||
| /** The maximum number of attempts allowed. */ | ||
| public int $limit, | ||
| /** The number of attempts remaining in the current window. */ | ||
| public int $remaining, | ||
| /** The Unix timestamp when the rate limit resets. */ | ||
| public int $resetAt, | ||
| /** The number of seconds until the rate limit resets. */ | ||
| public int $retryAfter, | ||
| ) {} | ||
|
|
||
| /** | ||
| * Create a successful result (request allowed). | ||
| */ | ||
| public static function allow(int $limit, int $remaining, int $resetAt): self | ||
| { | ||
| return new self( | ||
| allowed: true, | ||
| limit: $limit, | ||
| remaining: $remaining, | ||
| resetAt: $resetAt, | ||
| retryAfter: max(0, $resetAt - time()), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Create a failed result (request denied due to rate limit). | ||
| */ | ||
| public static function deny(int $limit, int $resetAt): self | ||
| { | ||
| return new self( | ||
| allowed: false, | ||
| limit: $limit, | ||
| remaining: 0, | ||
| resetAt: $resetAt, | ||
| retryAfter: max(0, $resetAt - time()), | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tempest\Cache\RateLimiting; | ||
|
|
||
| use Tempest\DateTime\DateTime; | ||
|
|
||
| /** | ||
| * A rate limiter tracks and enforces request limits. | ||
| */ | ||
| interface RateLimiter | ||
| { | ||
| /** | ||
| * Attempt to hit the rate limiter for the given key. | ||
| * | ||
| * @param string $key The unique identifier for this rate limit bucket. | ||
| * @param int $maxAttempts The maximum number of attempts allowed. | ||
| * @param int $decaySeconds The time window in seconds. | ||
| */ | ||
| public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateLimitResult; | ||
|
|
||
| /** | ||
| * Get the number of attempts for the given key. | ||
| */ | ||
| public function attempts(string $key): int; | ||
|
|
||
| /** | ||
| * Get the number of remaining attempts for the given key. | ||
| */ | ||
| public function remaining(string $key, int $maxAttempts): int; | ||
|
|
||
| /** | ||
| * Determine if the given key has been hit too many times. | ||
| */ | ||
| public function tooManyAttempts(string $key, int $maxAttempts): bool; | ||
|
|
||
| /** | ||
| * Clear the rate limiter for the given key. | ||
| */ | ||
| public function clear(string $key): void; | ||
|
|
||
| /** | ||
| * Get the date-time when the rate limit resets for the given key. | ||
| */ | ||
| public function availableAt(string $key): DateTime; | ||
| } |
115 changes: 115 additions & 0 deletions
115
packages/cache/tests/RateLimiting/CacheRateLimiterTest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tempest\Cache\Tests\RateLimiting; | ||
|
|
||
| use PHPUnit\Framework\TestCase; | ||
| use Tempest\Cache\RateLimiting\CacheRateLimiter; | ||
| use Tempest\Cache\Testing\TestingCache; | ||
| use Tempest\Clock\GenericClock; | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| final class CacheRateLimiterTest extends TestCase | ||
| { | ||
| private TestingCache $cache; | ||
|
|
||
| private CacheRateLimiter $rateLimiter; | ||
|
|
||
| protected function setUp(): void | ||
| { | ||
| $clock = new GenericClock(); | ||
| $this->cache = new TestingCache(tag: 'rate-limit-test', clock: $clock->toPsrClock()); | ||
| $this->rateLimiter = new CacheRateLimiter($this->cache); | ||
| } | ||
|
|
||
| public function test_attempt_allows_within_limit(): void | ||
| { | ||
| $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); | ||
|
|
||
| $this->assertTrue($result->allowed); | ||
| $this->assertSame(5, $result->limit); | ||
| $this->assertSame(4, $result->remaining); | ||
| } | ||
|
|
||
| public function test_attempt_denies_after_exceeding_limit(): void | ||
| { | ||
| // Make 5 attempts (the limit) | ||
| for ($i = 0; $i < 5; $i++) { | ||
| $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); | ||
| } | ||
|
|
||
| // 6th attempt should be denied | ||
| $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); | ||
|
|
||
| $this->assertFalse($result->allowed); | ||
| $this->assertSame(0, $result->remaining); | ||
| } | ||
|
|
||
| public function test_attempts_returns_count(): void | ||
| { | ||
| $this->assertSame(0, $this->rateLimiter->attempts('test-key')); | ||
|
|
||
| $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); | ||
| $this->assertSame(1, $this->rateLimiter->attempts('test-key')); | ||
|
|
||
| $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); | ||
| $this->assertSame(2, $this->rateLimiter->attempts('test-key')); | ||
| } | ||
|
|
||
| public function test_remaining_returns_available_attempts(): void | ||
| { | ||
| $this->assertSame(10, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); | ||
|
|
||
| $this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60); | ||
| $this->assertSame(9, $this->rateLimiter->remaining('test-key', maxAttempts: 10)); | ||
| } | ||
|
|
||
| public function test_too_many_attempts_returns_true_when_exceeded(): void | ||
| { | ||
| $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); | ||
|
|
||
| $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); | ||
| $this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); | ||
|
|
||
| $this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60); | ||
| $this->assertTrue($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2)); | ||
| } | ||
|
|
||
| public function test_clear_resets_the_counter(): void | ||
| { | ||
| $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); | ||
| $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); | ||
|
|
||
| $this->assertSame(2, $this->rateLimiter->attempts('test-key')); | ||
|
|
||
| $this->rateLimiter->clear('test-key'); | ||
|
|
||
| $this->assertSame(0, $this->rateLimiter->attempts('test-key')); | ||
| } | ||
|
|
||
| public function test_different_keys_are_independent(): void | ||
| { | ||
| $this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60); | ||
| $this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60); | ||
|
|
||
| // key-a is at limit | ||
| $this->assertTrue($this->rateLimiter->tooManyAttempts('key-a', maxAttempts: 2)); | ||
|
|
||
| // key-b should still be available | ||
| $this->assertFalse($this->rateLimiter->tooManyAttempts('key-b', maxAttempts: 2)); | ||
| } | ||
|
|
||
| public function test_reset_time_is_set_correctly(): void | ||
| { | ||
| $beforeTime = time(); | ||
| $result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60); | ||
| $afterTime = time(); | ||
|
|
||
| // Reset time should be approximately 60 seconds from now | ||
| $this->assertGreaterThanOrEqual($beforeTime + 60, $result->resetAt); | ||
| $this->assertLessThanOrEqual($afterTime + 60, $result->resetAt); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tempest\Cache\Tests\RateLimiting; | ||
|
|
||
| use PHPUnit\Framework\TestCase; | ||
| use Tempest\Cache\RateLimiting\RateLimitResult; | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| final class RateLimitResultTest extends TestCase | ||
| { | ||
| public function test_allow_creates_successful_result(): void | ||
| { | ||
| $resetAt = time() + 60; | ||
| $result = RateLimitResult::allow(limit: 100, remaining: 99, resetAt: $resetAt); | ||
|
|
||
| $this->assertTrue($result->allowed); | ||
| $this->assertSame(100, $result->limit); | ||
| $this->assertSame(99, $result->remaining); | ||
| $this->assertSame($resetAt, $result->resetAt); | ||
| $this->assertLessThanOrEqual(60, $result->retryAfter); | ||
| } | ||
|
|
||
| public function test_deny_creates_failed_result(): void | ||
| { | ||
| $resetAt = time() + 30; | ||
| $result = RateLimitResult::deny(limit: 100, resetAt: $resetAt); | ||
|
|
||
| $this->assertFalse($result->allowed); | ||
| $this->assertSame(100, $result->limit); | ||
| $this->assertSame(0, $result->remaining); | ||
| $this->assertSame($resetAt, $result->resetAt); | ||
| $this->assertLessThanOrEqual(30, $result->retryAfter); | ||
| } | ||
|
|
||
| public function test_retry_after_is_never_negative(): void | ||
| { | ||
| $pastTime = time() - 100; | ||
| $result = RateLimitResult::deny(limit: 100, resetAt: $pastTime); | ||
|
|
||
| $this->assertSame(0, $result->retryAfter); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.