From 47f4f05f9807a01af5dd33f2a31d2a6cd4405472 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 29 Aug 2026 13:04:43 +0100 Subject: [PATCH 1/5] feat(rate-limit): add rate limiting --- composer.json | 3 + docs/2-features/21-rate-limiting.md | 269 ++++++++++++++++ packages/rate-limit/.gitattributes | 14 + packages/rate-limit/LICENSE.md | 9 + packages/rate-limit/composer.json | 29 ++ packages/rate-limit/phpunit.xml | 23 ++ .../rate-limit/src/Config/RateLimitConfig.php | 58 ++++ .../src/Config/rateLimit.config.php | 7 + .../rate-limit/src/GenericRateLimiter.php | 77 +++++ .../src/Http/AddsThrottleMiddleware.php | 28 ++ .../src/Http/ClientIpKeyResolver.php | 22 ++ .../rate-limit/src/Http/RateLimitHeaders.php | 39 +++ .../src/Http/RateLimitKeyResolver.php | 19 ++ .../rate-limit/src/Http/RateLimitProfile.php | 27 ++ packages/rate-limit/src/Http/Throttle.php | 70 ++++ .../src/Http/ThrottleCounterKey.php | 67 ++++ .../src/Http/ThrottleMiddleware.php | 139 ++++++++ .../rate-limit/src/Http/ThrottleScope.php | 23 ++ packages/rate-limit/src/Http/ThrottleWith.php | 40 +++ packages/rate-limit/src/Http/Throttles.php | 25 ++ .../RateLimitKeyResolverInitializer.php | 20 ++ .../RateLimitStorageInitializer.php | 20 ++ .../Initializers/RateLimiterInitializer.php | 25 ++ packages/rate-limit/src/Per.php | 28 ++ packages/rate-limit/src/RateLimit.php | 71 ++++ .../rate-limit/src/RateLimitException.php | 12 + packages/rate-limit/src/RateLimitHasNoKey.php | 17 + packages/rate-limit/src/RateLimitResult.php | 76 +++++ packages/rate-limit/src/RateLimitStorage.php | 26 ++ .../rate-limit/src/RateLimitWasExceeded.php | 19 ++ packages/rate-limit/src/RateLimiter.php | 37 +++ .../src/Storage/CacheRateLimitStorage.php | 67 ++++ .../rate-limit/src/Storage/RateLimitState.php | 57 ++++ .../src/Storage/RateLimitStorageFailed.php | 15 + .../src/Storage/RedisRateLimitStorage.php | 98 ++++++ .../src/Testing/RateLimitTester.php | 151 +++++++++ .../src/Testing/TestingRateLimitStorage.php | 51 +++ packages/rate-limit/tests/RateLimiterTest.php | 219 +++++++++++++ packages/rate-limit/tests/ThrottleTest.php | 49 +++ .../Framework/Testing/IntegrationTest.php | 7 + .../Controllers/ClassThrottledController.php | 32 ++ .../Controllers/ThrottledController.php | 101 ++++++ .../RateLimit/PremiumRateLimitProfile.php | 21 ++ .../RateLimit/TieredRateLimitProfile.php | 23 ++ .../RateLimit/UnidentifiedKeyResolver.php | 19 ++ .../RateLimit/RateLimitTesterTest.php | 100 ++++++ .../RateLimit/RedisRateLimitStorageTest.php | 131 ++++++++ .../RateLimit/ThrottleMiddlewareTest.php | 303 ++++++++++++++++++ 48 files changed, 2783 insertions(+) create mode 100644 docs/2-features/21-rate-limiting.md create mode 100644 packages/rate-limit/.gitattributes create mode 100644 packages/rate-limit/LICENSE.md create mode 100644 packages/rate-limit/composer.json create mode 100644 packages/rate-limit/phpunit.xml create mode 100644 packages/rate-limit/src/Config/RateLimitConfig.php create mode 100644 packages/rate-limit/src/Config/rateLimit.config.php create mode 100644 packages/rate-limit/src/GenericRateLimiter.php create mode 100644 packages/rate-limit/src/Http/AddsThrottleMiddleware.php create mode 100644 packages/rate-limit/src/Http/ClientIpKeyResolver.php create mode 100644 packages/rate-limit/src/Http/RateLimitHeaders.php create mode 100644 packages/rate-limit/src/Http/RateLimitKeyResolver.php create mode 100644 packages/rate-limit/src/Http/RateLimitProfile.php create mode 100644 packages/rate-limit/src/Http/Throttle.php create mode 100644 packages/rate-limit/src/Http/ThrottleCounterKey.php create mode 100644 packages/rate-limit/src/Http/ThrottleMiddleware.php create mode 100644 packages/rate-limit/src/Http/ThrottleScope.php create mode 100644 packages/rate-limit/src/Http/ThrottleWith.php create mode 100644 packages/rate-limit/src/Http/Throttles.php create mode 100644 packages/rate-limit/src/Initializers/RateLimitKeyResolverInitializer.php create mode 100644 packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php create mode 100644 packages/rate-limit/src/Initializers/RateLimiterInitializer.php create mode 100644 packages/rate-limit/src/Per.php create mode 100644 packages/rate-limit/src/RateLimit.php create mode 100644 packages/rate-limit/src/RateLimitException.php create mode 100644 packages/rate-limit/src/RateLimitHasNoKey.php create mode 100644 packages/rate-limit/src/RateLimitResult.php create mode 100644 packages/rate-limit/src/RateLimitStorage.php create mode 100644 packages/rate-limit/src/RateLimitWasExceeded.php create mode 100644 packages/rate-limit/src/RateLimiter.php create mode 100644 packages/rate-limit/src/Storage/CacheRateLimitStorage.php create mode 100644 packages/rate-limit/src/Storage/RateLimitState.php create mode 100644 packages/rate-limit/src/Storage/RateLimitStorageFailed.php create mode 100644 packages/rate-limit/src/Storage/RedisRateLimitStorage.php create mode 100644 packages/rate-limit/src/Testing/RateLimitTester.php create mode 100644 packages/rate-limit/src/Testing/TestingRateLimitStorage.php create mode 100644 packages/rate-limit/tests/RateLimiterTest.php create mode 100644 packages/rate-limit/tests/ThrottleTest.php create mode 100644 tests/Fixtures/Controllers/ClassThrottledController.php create mode 100644 tests/Fixtures/Controllers/ThrottledController.php create mode 100644 tests/Fixtures/RateLimit/PremiumRateLimitProfile.php create mode 100644 tests/Fixtures/RateLimit/TieredRateLimitProfile.php create mode 100644 tests/Fixtures/RateLimit/UnidentifiedKeyResolver.php create mode 100644 tests/Integration/RateLimit/RateLimitTesterTest.php create mode 100644 tests/Integration/RateLimit/RedisRateLimitStorageTest.php create mode 100644 tests/Integration/RateLimit/ThrottleMiddlewareTest.php diff --git a/composer.json b/composer.json index 8b52e1f50f..400d1611f8 100644 --- a/composer.json +++ b/composer.json @@ -120,6 +120,7 @@ "tempest/mapper": "self.version", "tempest/mcp": "self.version", "tempest/process": "self.version", + "tempest/rate-limit": "self.version", "tempest/reflection": "self.version", "tempest/router": "self.version", "tempest/storage": "self.version", @@ -163,6 +164,7 @@ "Tempest\\Mapper\\": "packages/mapper/src", "Tempest\\Mcp\\": "packages/mcp/src", "Tempest\\Process\\": "packages/process/src", + "Tempest\\RateLimit\\": "packages/rate-limit/src", "Tempest\\Reflection\\": "packages/reflection/src", "Tempest\\Router\\": "packages/router/src", "Tempest\\Storage\\": "packages/storage/src", @@ -238,6 +240,7 @@ "Tempest\\Mapper\\Tests\\": "packages/mapper/tests", "Tempest\\Mcp\\Tests\\": "packages/mcp/tests", "Tempest\\Process\\Tests\\": "packages/process/tests", + "Tempest\\RateLimit\\Tests\\": "packages/rate-limit/tests", "Tempest\\Rector\\": "utils/rector/src", "Tempest\\Reflection\\Tests\\": "packages/reflection/tests", "Tempest\\Router\\Tests\\": "packages/router/tests", diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md new file mode 100644 index 0000000000..49fbcc58e7 --- /dev/null +++ b/docs/2-features/21-rate-limiting.md @@ -0,0 +1,269 @@ +--- +title: Rate limiting +description: "Limit how often a route may be requested, or throttle any operation, by counting attempts against a key within a window of time." +--- + +## Overview + +The `tempest/rate-limit` package provides a {b`Tempest\RateLimit\RateLimiter`} for throttling any operation, alongside the {b`Tempest\RateLimit\Http\Throttle`} attribute for managing routes. + +Counters are stored in the [cache](./06-cache.md) by default, requiring no extra infrastructure out of the box. For high-concurrency production environments, switch to [Redis](#storage) for atomic counting. + +## Throttling routes + +Add the {b`Tempest\RateLimit\Http\Throttle`} attribute to a controller method: + +```php app/PostController.php +use Tempest\RateLimit\Http\Throttle; +use Tempest\Router\Get; + +final readonly class PostController +{ + #[Throttle(attempts: 60)] + #[Get('/api/posts')] + public function index(): Response + { /* … */ } +} + +``` + +The window defaults to one minute. To extend it, specify `per` and `every`: + +```php +use Tempest\RateLimit\Per; + +#[Throttle(attempts: 1000, per: Per::DAY)] +#[Throttle(attempts: 10, per: Per::MINUTE, every: 5)] + +``` + +By default, every route and every client gets an independent counter. To share a limit across multiple routes, assign a common `bucket`: + +```php +#[Throttle(attempts: 100, bucket: 'api')] + +``` + +A named bucket scopes the limit entirely to the client, allowing multiple routes to draw from the same allowance. Unnamed limits are automatically scoped by their exact allowance criteria, meaning attributes can be reordered freely without breaking counters. + +Changing an allowance resets its counter, lifting current limits. Use a named bucket if a counter needs to persist across configuration adjustments. + +You can also apply `#[Throttle]` directly to a controller class. This applies the allowance globally to all routes exposed by the controller, while method-level limits stack on top to narrow allowances further. + +Allowed requests pass through normally with rate limit headers appended: + +``` +X-RateLimit-Limit: 60 +X-RateLimit-Remaining: 58 +X-RateLimit-Reset: 1767225600 + +``` + +Exceeded limits return a `429 Too Many Requests` status paired with a `Retry-After` header. + +Because rate limit headers represent a single client's unique usage, responses carrying them should not be shared via proxy caches. Disable headers entirely by setting `includeHeaders: false` in {b`Tempest\RateLimit\Config\RateLimitConfig`}, or turn off throttling completely during development via `enabled: false`. + +### Multiple limits + +Because the attribute is repeatable, routes can combine multiple limits, such as pairing a strict burst threshold with a broad daily quota: + +```php +#[Throttle(attempts: 20)] +#[Throttle(attempts: 1000, per: Per::DAY)] +#[Get('/api/posts')] +public function index(): Response +{ /* … */ } + +``` + +Limits evaluate sequentially—starting with route-level rules and following up with controller-level rules. Evaluation halts on the first rejection, preventing clients from burning through long-term quotas while spamming short-term burst limits. + +## Choosing what to count + +Requests default to tracking against the client's IP address via {b`Tempest\RateLimit\Http\ClientIpKeyResolver`}, utilizing packed formats so `::ffff:127.0.0.1` and `127.0.0.1` share a single counter. + +Applications behind a reverse proxy must configure trusted proxies in {b`Tempest\Http\Ip\TrustedProxiesConfig`} (see the [trusted proxies documentation](../1-essentials/01-routing.md#trusted-proxies)). Without this, all incoming proxy requests collapse into a single shared counter. + +To track limits by authenticated users or API keys instead, implement {b`Tempest\RateLimit\Http\RateLimitKeyResolver`}: + +```php app/ApiKeyResolver.php +use Tempest\Http\Request; +use Tempest\RateLimit\Http\RateLimitKeyResolver; + +final readonly class ApiKeyResolver implements RateLimitKeyResolver +{ + public function resolve(Request $request): ?string + { + return $request->headers->get('x-api-key') ?? $request->ip?->toString(); + } +} + +``` + +Register your resolver in the configuration: + +```php app/rateLimit.config.php +use Tempest\RateLimit\Config\RateLimitConfig; + +return new RateLimitConfig( + keyResolverClass: ApiKeyResolver::class, +); + +``` + +Resolvers should return `null` for unidentifiable requests, routing them into a collective shared bucket so anonymous traffic remains strictly throttled. + +## Limits that depend on the request + +Dynamic limits—such as granting higher tiers to paying customers while leaving internal traffic unlimited—can be implemented using {b`Tempest\RateLimit\Http\RateLimitProfile`}: + +```php app/ApiRateLimitProfile.php +use Tempest\Http\Request; +use Tempest\RateLimit\Http\RateLimitProfile; +use Tempest\RateLimit\Per; +use Tempest\RateLimit\RateLimit; + +final readonly class ApiRateLimitProfile implements RateLimitProfile +{ + public function resolve(Request $request): array + { + if ($request->headers->get('x-api-key') === null) { + return [RateLimit::perMinute(20)]; + } + + return [ + RateLimit::perMinute(200), + RateLimit::perDay(100_000), + ]; + } +} + +``` + +Reference the profile using the {b`Tempest\RateLimit\Http\ThrottleWith`} attribute: + +```php +use Tempest\RateLimit\Http\ThrottleWith; + +#[ThrottleWith(ApiRateLimitProfile::class)] +#[Get('/api/posts')] +public function index(): Response +{ /* … */ } + +``` + +Profile limits scope similarly to `#[Throttle]` attributes. Unkeyed limits generate individual counters per route and client, while `withKey()` transforms them into shared buckets. Returning an empty array leaves requests completely unlimited. + +## Throttling anything else + +The limiter operates independently of HTTP. Inject {b`Tempest\RateLimit\RateLimiter`} to protect any background operation, outgoing request, or resource-heavy job: + +```php +use Tempest\RateLimit\RateLimit; +use Tempest\RateLimit\RateLimiter; + +final readonly class SendVerificationEmail +{ + public function __construct( + private RateLimiter $limiter, + ) {} + + public function __invoke(User $user): void + { + $limit = RateLimit::perHour(3)->withKey("verification-email:{$user->id}"); + + if ($this->limiter->attempt($limit)->exceeded) { + return; + } + + // … + } +} + +``` + +Build limits using `RateLimit::perSecond()`, `perMinute()`, `perHour()`, or `perDay()`, optionally passing a multiplier as the second argument. Use `withKey()` to scope a limit to a key, or `scopedTo()` to append to the key it already has. A limit must carry a key by the time it reaches the limiter—keyless limits throw {b`Tempest\RateLimit\RateLimitHasNoKey`} rather than being guessed at, since they would otherwise all share a single counter. + +The `attempt()` method records attempts and returns a {b`Tempest\RateLimit\RateLimitResult`}: + +```php +$result = $this->limiter->attempt($limit, by: 1); + +$result->allowed; // whether the attempt fits within the limit +$result->exceeded; // the inverse +$result->limit; // the maximum amount of attempts +$result->hits; // attempts made in the current window +$result->remaining; // attempts left in the current window +$result->retryAfter; // a Duration to wait for, zero when allowed +$result->resetsAt; // when the window expires + +``` + +Use `peek()` to check limits without incrementing hits, or `clear()` to reset records (such as after a successful login). The `throttle()` method executes callbacks conditionally: + +```php +$this->limiter->throttle($limit, function () { + // … +}); + +``` + +Exceeding limits via `throttle()` throws {b`Tempest\RateLimit\RateLimitWasExceeded`} (extending {b`Tempest\RateLimit\RateLimitException`}), carrying the result payload for clean error handling. Manual limit management gives you direct control over custom domain objects, accounts, or tenants, requiring you to handle rejections explicitly via try-catch blocks or conditional `attempt()` branches. + +## Storage + +Windows are managed via {b`Tempest\RateLimit\RateLimitStorage`}. Tempest defaults to {b`Tempest\RateLimit\Storage\CacheRateLimitStorage`}, which requires no external services beyond a standard [cache](./06-cache.md). Because it serialises updates using locks rather than atomic operations, concurrent loads may lead to undercounting. It is also only as durable as the cache itself—when the cache is disabled, no counter is persisted and no limit is ever reached. + +For high-concurrency production environments, switch to {b`Tempest\RateLimit\Storage\RedisRateLimitStorage`} to utilize atomic Lua-script increments: + +```php app/rateLimit.config.php +use Tempest\RateLimit\Config\RateLimitConfig; +use Tempest\RateLimit\Storage\RedisRateLimitStorage; + +return new RateLimitConfig( + storageClass: RedisRateLimitStorage::class, +); + +``` + +Custom storage engines can be integrated by pointing `storageClass` to any custom implementation of the storage interface. + +## Testing + +{b`Tempest\RateLimit\Testing\RateLimitTester`} is accessible directly on `IntegrationTest` as `$this->rateLimit`. Calling `fake()` swaps the storage layer for an isolated in-memory driver, eliminating external infrastructure dependencies and test leakage: + +```php +$this->rateLimit->fake(); + +$limit = RateLimit::perMinute(3)->withKey('login'); + +$this->rateLimit + ->hit($limit, times: 2) + ->assertHits($limit, 2) + ->assertRemaining($limit, 1) + ->assertNotThrottled($limit); + +$this->rateLimit + ->exhaust($limit) + ->assertThrottled($limit); + +``` + +Windows expire against the clock, so a mocked clock moved past the end of a window reopens it. Use `clear()` to discard the attempts recorded for a single limit between assertions, or call `fake()` again to discard all of them. + +To disable route-level throttling across tests while keeping manual `RateLimiter` calls active, use: + +```php +$this->rateLimit->preventThrottling(); + +``` + +HTTP tests interact with throttled routes naturally through simulated requests: + +```php +$this->http->fromIp('203.0.113.9')->get('/api/posts')->assertOk(); +$this->http->fromIp('203.0.113.9')->get('/api/posts')->assertStatus(Status::TOO_MANY_REQUESTS); + +``` + +The counters behind `#[Throttle]` are keyed internally and are not addressable from a test. To assert against one directly, give the limit a named `bucket` and consume it through {b`Tempest\RateLimit\RateLimiter`}. diff --git a/packages/rate-limit/.gitattributes b/packages/rate-limit/.gitattributes new file mode 100644 index 0000000000..3f7775660b --- /dev/null +++ b/packages/rate-limit/.gitattributes @@ -0,0 +1,14 @@ +# Exclude build/test files from the release +.github/ export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpunit.xml export-ignore +README.md export-ignore + +# Configure diff output +*.view.php diff=html +*.php diff=php +*.css diff=css +*.html diff=html +*.md diff=markdown diff --git a/packages/rate-limit/LICENSE.md b/packages/rate-limit/LICENSE.md new file mode 100644 index 0000000000..54215b7261 --- /dev/null +++ b/packages/rate-limit/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2024 Brent Roose brendt@stitcher.io + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/rate-limit/composer.json b/packages/rate-limit/composer.json new file mode 100644 index 0000000000..dbe004ca5a --- /dev/null +++ b/packages/rate-limit/composer.json @@ -0,0 +1,29 @@ +{ + "name": "tempest/rate-limit", + "description": "Rate limiting for Tempest applications.", + "type": "library", + "require": { + "php": "^8.5", + "tempest/cache": "3.x-dev", + "tempest/clock": "3.x-dev", + "tempest/container": "3.x-dev", + "tempest/core": "3.x-dev", + "tempest/datetime": "3.x-dev", + "tempest/http": "3.x-dev", + "tempest/kv-store": "3.x-dev", + "tempest/router": "3.x-dev", + "tempest/support": "3.x-dev" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "Tempest\\RateLimit\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tempest\\RateLimit\\Tests\\": "tests" + } + }, + "minimum-stability": "dev" +} diff --git a/packages/rate-limit/phpunit.xml b/packages/rate-limit/phpunit.xml new file mode 100644 index 0000000000..f0c39c212b --- /dev/null +++ b/packages/rate-limit/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + diff --git a/packages/rate-limit/src/Config/RateLimitConfig.php b/packages/rate-limit/src/Config/RateLimitConfig.php new file mode 100644 index 0000000000..67080ec422 --- /dev/null +++ b/packages/rate-limit/src/Config/RateLimitConfig.php @@ -0,0 +1,58 @@ + + */ + public string $storageClass = CacheRateLimitStorage::class, + + /** @var class-string */ + public string $keyResolverClass = ClientIpKeyResolver::class, + ) {} + + /** + * Returns the key a rate limit's window is stored under. Keys are hashed, since a limit may be + * scoped to arbitrary input that the store would not accept as a key. + */ + public function storageKey(string $key): string + { + return $this->keyPrefix . '_' . hash('xxh128', $key); + } +} diff --git a/packages/rate-limit/src/Config/rateLimit.config.php b/packages/rate-limit/src/Config/rateLimit.config.php new file mode 100644 index 0000000000..c425b8ac43 --- /dev/null +++ b/packages/rate-limit/src/Config/rateLimit.config.php @@ -0,0 +1,7 @@ +toResult($limit, $this->storage->increment($this->key($limit), $limit->window, $by), consumed: true); + } + + public function peek(RateLimit $limit): RateLimitResult + { + return $this->toResult($limit, $this->storage->find($this->key($limit)), consumed: false); + } + + public function throttle(RateLimit $limit, Closure $callback): mixed + { + $result = $this->attempt($limit); + + if ($result->exceeded) { + throw new RateLimitWasExceeded($result); + } + + return $callback(); + } + + public function clear(RateLimit $limit): void + { + $this->storage->remove($this->key($limit)); + } + + /** + * Returns the key the limit is counted under. Keyless limits are rejected rather than guessed at, + * as they would all share a single counter. + */ + private function key(RateLimit $limit): string + { + return $limit->key ?? throw RateLimitHasNoKey::forLimit($limit); + } + + /** + * @param bool $consumed Whether `$state` already includes the attempt being evaluated. + */ + private function toResult(RateLimit $limit, ?RateLimitState $state, bool $consumed): RateLimitResult + { + // Nothing has been counted yet, and no window is open. Opening one here would report a + // reset for a window that no attempt belongs to. + $state ??= new RateLimitState(hits: 0, resetsAtInSeconds: $this->clock->seconds()); + $allowed = $consumed + ? $state->hits <= $limit->attempts + : $state->hits < $limit->attempts; + + return new RateLimitResult( + key: $this->key($limit), + allowed: $allowed, + limit: $limit->attempts, + hits: $state->hits, + resetsAtInSeconds: $state->resetsAtInSeconds, + // Only a rejected attempt has to wait. Reporting a delay on an allowed one would have + // a client back off while it still has attempts left. + retryAfterInSeconds: $allowed ? 0 : max(0, $state->resetsAtInSeconds - $this->clock->seconds()), + ); + } +} diff --git a/packages/rate-limit/src/Http/AddsThrottleMiddleware.php b/packages/rate-limit/src/Http/AddsThrottleMiddleware.php new file mode 100644 index 0000000000..9c02b5362b --- /dev/null +++ b/packages/rate-limit/src/Http/AddsThrottleMiddleware.php @@ -0,0 +1,28 @@ +middleware, strict: true)) { + return $route; + } + + $route->middleware = [ + ...$route->middleware, + ThrottleMiddleware::class, + ]; + + return $route; + } +} diff --git a/packages/rate-limit/src/Http/ClientIpKeyResolver.php b/packages/rate-limit/src/Http/ClientIpKeyResolver.php new file mode 100644 index 0000000000..6454d20c98 --- /dev/null +++ b/packages/rate-limit/src/Http/ClientIpKeyResolver.php @@ -0,0 +1,22 @@ +ip === null + ? null + : bin2hex($request->ip->bytes); + } +} diff --git a/packages/rate-limit/src/Http/RateLimitHeaders.php b/packages/rate-limit/src/Http/RateLimitHeaders.php new file mode 100644 index 0000000000..ebc6a9a9f0 --- /dev/null +++ b/packages/rate-limit/src/Http/RateLimitHeaders.php @@ -0,0 +1,39 @@ + + */ + public static function for(RateLimitResult $result, RateLimitConfig $config): array + { + $headers = $result->exceeded + ? ['Retry-After' => (string) $result->retryAfterInSeconds] + : []; + + if (! $config->includeHeaders) { + return $headers; + } + + return [ + ...$headers, + 'X-RateLimit-Limit' => (string) $result->limit, + 'X-RateLimit-Remaining' => (string) $result->remaining, + 'X-RateLimit-Reset' => (string) $result->resetsAtInSeconds, + ]; + } +} diff --git a/packages/rate-limit/src/Http/RateLimitKeyResolver.php b/packages/rate-limit/src/Http/RateLimitKeyResolver.php new file mode 100644 index 0000000000..2282538935 --- /dev/null +++ b/packages/rate-limit/src/Http/RateLimitKeyResolver.php @@ -0,0 +1,19 @@ +toRateLimit()]; + } + + /** + * Returns the rate limit described by this attribute. + */ + public function toRateLimit(): RateLimit + { + return new RateLimit( + attempts: $this->attempts, + window: $this->per->toDuration($this->every), + key: $this->bucket, + ); + } +} diff --git a/packages/rate-limit/src/Http/ThrottleCounterKey.php b/packages/rate-limit/src/Http/ThrottleCounterKey.php new file mode 100644 index 0000000000..08f0709af7 --- /dev/null +++ b/packages/rate-limit/src/Http/ThrottleCounterKey.php @@ -0,0 +1,67 @@ +key !== null) { + return implode(':', ['bucket', $limit->key, $client]); + } + + return implode(':', [ + ...self::scope($matchedRoute, $scope), + self::allowance($limit), + $client, + ]); + } + + /** + * Returns what the limit is counted against, on top of the client. + * + * @return string[] + */ + private static function scope(MatchedRoute $matchedRoute, ThrottleScope $scope): array + { + $handler = $matchedRoute->route->handler; + + return match ($scope) { + ThrottleScope::CONTROLLER => [ + $handler->getDeclaringClass()->getName(), + $scope->value, + ], + ThrottleScope::ROUTE => [ + $handler->getDeclaringClass()->getName(), + $handler->getName(), + $matchedRoute->route->uri, + $scope->value, + ], + }; + } + + /** + * Tells an unnamed limit apart from the ones declared alongside it. The allowance is used rather + * than the declaration order: inserting an attribute leaves existing counters in place, and limits + * describing the same allowance land in the same counter, as they are one limit, not two. + */ + private static function allowance(RateLimit $limit): string + { + return "{$limit->attempts}_{$limit->window->getTotalSeconds()}"; + } +} diff --git a/packages/rate-limit/src/Http/ThrottleMiddleware.php b/packages/rate-limit/src/Http/ThrottleMiddleware.php new file mode 100644 index 0000000000..3e525397d2 --- /dev/null +++ b/packages/rate-limit/src/Http/ThrottleMiddleware.php @@ -0,0 +1,139 @@ +config->enabled) { + return $next($request); + } + + $limits = $this->resolveLimits($request); + + if ($limits === []) { + return $next($request); + } + + $results = []; + + // The first rejection stops the rest. A request turned away by a narrow window does not + // also spend the wider allowances behind it. + foreach ($limits as $limit) { + $result = $this->limiter->attempt($limit); + + if ($result->exceeded) { + $this->reject($result); + } + + $results[] = $result; + } + + $response = $next($request); + + foreach (RateLimitHeaders::for($this->mostConstrained(...$results), $this->config) as $name => $value) { + $response->addHeader($name, $value); + } + + return $response; + } + + /** + * @return RateLimit[] + */ + private function resolveLimits(Request $request): array + { + // Resolving a client may be more than reading an address. It's done once for all limits. + $client = $this->keyResolver->resolve($request); + $limits = []; + + foreach ($this->resolveAttributes() as $scope => $throttles) { + foreach ($throttles as $throttle) { + foreach ($throttle->resolveLimits($request, $this->container) as $limit) { + $key = ThrottleCounterKey::for($limit, $this->matchedRoute, ThrottleScope::from($scope), $client); + + // Limits landing in the same counter describe one allowance: declaring the + // same limit twice throttles a route exactly once. + $limits[$key] = $limit->withKey($key); + } + } + } + + $limits = array_values($limits); + + // Narrow windows are consumed first, this way requests rejected by a per-minute limit + // leave the daily allowance untouched. It also keeps the outcome independent of the + // order the attributes were declared in. + usort($limits, fn (RateLimit $a, RateLimit $b) => $a->window->getTotalSeconds() <=> $b->window->getTotalSeconds()); + + return $limits; + } + + /** + * Returns the throttling attributes declared on the route and on its controller. The route's own + * limits come first. A request rejected by one route then leaves the allowance it shares with its + * siblings intact. Sorting is stable, and {@see self::resolveLimits()} preserves that order. + * + * @return array + */ + private function resolveAttributes(): array + { + $handler = $this->matchedRoute->route->handler; + + return array_filter([ + ThrottleScope::ROUTE->value => $handler->getAttributes(Throttles::class), + ThrottleScope::CONTROLLER->value => $handler->getDeclaringClass()->getAttributes(Throttles::class), + ]); + } + + private function mostConstrained(RateLimitResult $result, RateLimitResult ...$others): RateLimitResult + { + return array_reduce( + array: $others, + callback: fn (RateLimitResult $carry, RateLimitResult $other) => $other->remaining < $carry->remaining ? $other : $carry, + initial: $result, + ); + } + + /** + * Rejects the request. Error responses are rendered from scratch. Headers set on a response + * would be discarded. + */ + private function reject(RateLimitResult $result): never + { + throw new HttpRequestFailed( + status: Status::TOO_MANY_REQUESTS, + headers: RateLimitHeaders::for($result, $this->config), + ); + } +} diff --git a/packages/rate-limit/src/Http/ThrottleScope.php b/packages/rate-limit/src/Http/ThrottleScope.php new file mode 100644 index 0000000000..993c2b5f79 --- /dev/null +++ b/packages/rate-limit/src/Http/ThrottleScope.php @@ -0,0 +1,23 @@ + + */ + public string $profile, + ) {} + + public function resolveLimits(Request $request, Container $container): array + { + return $container->get($this->profile)->resolve($request); + } +} diff --git a/packages/rate-limit/src/Http/Throttles.php b/packages/rate-limit/src/Http/Throttles.php new file mode 100644 index 0000000000..44e30afaaf --- /dev/null +++ b/packages/rate-limit/src/Http/Throttles.php @@ -0,0 +1,25 @@ +get($container->get(RateLimitConfig::class)->keyResolverClass); + } +} diff --git a/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php b/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php new file mode 100644 index 0000000000..9c8ff74a55 --- /dev/null +++ b/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php @@ -0,0 +1,20 @@ +get($container->get(RateLimitConfig::class)->storageClass); + } +} diff --git a/packages/rate-limit/src/Initializers/RateLimiterInitializer.php b/packages/rate-limit/src/Initializers/RateLimiterInitializer.php new file mode 100644 index 0000000000..00ac1be4d3 --- /dev/null +++ b/packages/rate-limit/src/Initializers/RateLimiterInitializer.php @@ -0,0 +1,25 @@ +get(RateLimitStorage::class), + clock: $container->get(Clock::class), + ); + } +} diff --git a/packages/rate-limit/src/Per.php b/packages/rate-limit/src/Per.php new file mode 100644 index 0000000000..5dba03dbeb --- /dev/null +++ b/packages/rate-limit/src/Per.php @@ -0,0 +1,28 @@ + Duration::seconds($count), + self::MINUTE => Duration::minutes($count), + self::HOUR => Duration::hours($count), + self::DAY => Duration::days($count), + }; + } +} diff --git a/packages/rate-limit/src/RateLimit.php b/packages/rate-limit/src/RateLimit.php new file mode 100644 index 0000000000..d03bb6f767 --- /dev/null +++ b/packages/rate-limit/src/RateLimit.php @@ -0,0 +1,71 @@ +toDuration($seconds)); + } + + public static function perMinute(int $attempts, int $minutes = 1): self + { + return new self($attempts, Per::MINUTE->toDuration($minutes)); + } + + public static function perHour(int $attempts, int $hours = 1): self + { + return new self($attempts, Per::HOUR->toDuration($hours)); + } + + public static function perDay(int $attempts, int $days = 1): self + { + return new self($attempts, Per::DAY->toDuration($days)); + } + + /** + * Returns a copy of this rate limit scoped to the specified key. + */ + public function withKey(Stringable|string $key): self + { + return new self( + attempts: $this->attempts, + window: $this->window, + key: (string) $key, + ); + } + + /** + * Returns a copy of this rate limit with the specified key appended to the current one. + */ + public function scopedTo(Stringable|string $key): self + { + return $this->withKey($this->key === null ? (string) $key : $this->key . ':' . $key); + } +} diff --git a/packages/rate-limit/src/RateLimitException.php b/packages/rate-limit/src/RateLimitException.php new file mode 100644 index 0000000000..be859fb445 --- /dev/null +++ b/packages/rate-limit/src/RateLimitException.php @@ -0,0 +1,12 @@ +attempts, + )); + } +} diff --git a/packages/rate-limit/src/RateLimitResult.php b/packages/rate-limit/src/RateLimitResult.php new file mode 100644 index 0000000000..769f8bb2bb --- /dev/null +++ b/packages/rate-limit/src/RateLimitResult.php @@ -0,0 +1,76 @@ + ! $this->allowed; + } + + /** + * The amount of attempts left within the current window. + */ + public int $remaining { + get => max(0, $this->limit - $this->hits); + } + + /** + * The moment at which the current window ends and attempts become available again. + */ + public DateTimeInterface $resetsAt { + get => DateTime::fromTimestamp($this->resetsAtInSeconds); + } + + /** + * How long to wait before attempting again. + */ + public Duration $retryAfter { + get => Duration::seconds($this->retryAfterInSeconds); + } +} diff --git a/packages/rate-limit/src/RateLimitStorage.php b/packages/rate-limit/src/RateLimitStorage.php new file mode 100644 index 0000000000..b2c8f8c50b --- /dev/null +++ b/packages/rate-limit/src/RateLimitStorage.php @@ -0,0 +1,26 @@ +limit, + $result->key, + $result->retryAfterInSeconds, + )); + } +} diff --git a/packages/rate-limit/src/RateLimiter.php b/packages/rate-limit/src/RateLimiter.php new file mode 100644 index 0000000000..19a9a38123 --- /dev/null +++ b/packages/rate-limit/src/RateLimiter.php @@ -0,0 +1,37 @@ +cache->get($this->config->storageKey($key)); + + if (! $state instanceof RateLimitState) { + return null; + } + + // Cache expiry may drift from the clock's time. + if ($state->resetsAtInSeconds <= $this->clock->seconds()) { + return null; + } + + return $state; + } + + public function increment(string $key, Duration $window, int $by = 1): RateLimitState + { + $lock = $this->cache->lock( + key: $this->config->storageKey($key) . '_lock', + duration: Duration::seconds($this->config->lockTimeoutInSeconds), + ); + + return $lock->execute( + callback: function () use ($key, $window, $by): RateLimitState { + $state = $this->find($key)?->incrementedBy($by) ?? RateLimitState::opening($this->clock, $window, hits: $by); + + $this->cache->put( + key: $this->config->storageKey($key), + value: $state, + expiration: Duration::seconds(max(1, $state->resetsAtInSeconds - $this->clock->seconds())), + ); + + return $state; + }, + wait: Duration::seconds($this->config->lockTimeoutInSeconds), + ); + } + + public function remove(string $key): void + { + $this->cache->remove($this->config->storageKey($key)); + } +} diff --git a/packages/rate-limit/src/Storage/RateLimitState.php b/packages/rate-limit/src/Storage/RateLimitState.php new file mode 100644 index 0000000000..5f9c605760 --- /dev/null +++ b/packages/rate-limit/src/Storage/RateLimitState.php @@ -0,0 +1,57 @@ +seconds() + self::windowInSeconds($window), + ); + } + + /** + * Returns the length of the specified window, in seconds. Expiration is second-granular: + * one second is the shortest window that can be honored. + */ + public static function windowInSeconds(Duration $window): int + { + return max(1, (int) ceil($window->getTotalSeconds())); + } + + /** + * Records attempts within the current window, leaving its end untouched. + */ + public function incrementedBy(int $by): self + { + return new self( + hits: $this->hits + $by, + resetsAtInSeconds: $this->resetsAtInSeconds, + ); + } +} diff --git a/packages/rate-limit/src/Storage/RateLimitStorageFailed.php b/packages/rate-limit/src/Storage/RateLimitStorageFailed.php new file mode 100644 index 0000000000..a2e7b4a3ed --- /dev/null +++ b/packages/rate-limit/src/Storage/RateLimitStorageFailed.php @@ -0,0 +1,15 @@ +toState($this->eval(self::FIND, $key)); + } + + public function increment(string $key, Duration $window, int $by = 1): RateLimitState + { + $windowInSeconds = RateLimitState::windowInSeconds($window); + + return $this->toState($this->eval(self::INCREMENT, $key, (string) $windowInSeconds, (string) $by)) ?? throw RateLimitStorageFailed::redisDidNotReportAWindow($key); + } + + public function remove(string $key): void + { + $this->redis->command('DEL', $this->config->storageKey($key)); + } + + /** + * Runs one of the scripts above against a single key. Raw commands bypass the client's prefix. The + * key is derived here. + * + * Scripts are sent with `EVAL` rather than cached with `EVALSHA`, as they are a couple of hundred + * bytes and the supported clients disagree on how a missing script is signalled. + */ + private function eval(string $script, string $key, string ...$arguments): mixed + { + return $this->redis->command('EVAL', $script, '1', $this->config->storageKey($key), ...$arguments); + } + + /** + * @param mixed $reply The `{hits, ttl}` pair replied by one of the scripts, or `false` when no window is open. + */ + private function toState(mixed $reply): ?RateLimitState + { + if (! is_array($reply)) { + return null; + } + + [$hits, $timeToLiveInSeconds] = $reply; + + return new RateLimitState( + hits: (int) $hits, + resetsAtInSeconds: $this->clock->seconds() + max(0, (int) $timeToLiveInSeconds), + ); + } +} diff --git a/packages/rate-limit/src/Testing/RateLimitTester.php b/packages/rate-limit/src/Testing/RateLimitTester.php new file mode 100644 index 0000000000..fbe0d87713 --- /dev/null +++ b/packages/rate-limit/src/Testing/RateLimitTester.php @@ -0,0 +1,151 @@ +container->get(Clock::class), + ); + + $this->container->singleton(RateLimitStorage::class, $storage); + + // The limiter holds on to the storage it was built with. It's rebuilt around the new one. + $this->container->singleton(RateLimiter::class, new GenericRateLimiter( + storage: $storage, + clock: $this->container->get(Clock::class), + )); + + return $this; + } + + /** + * Leaves routes decorated with {@see \Tempest\RateLimit\Http\Throttle} unlimited. Limits consumed + * directly through {@see RateLimiter} are not affected. + */ + public function preventThrottling(): self + { + $this->container->get(RateLimitConfig::class)->enabled = false; + + return $this; + } + + /** + * Applies the limits declared by {@see \Tempest\RateLimit\Http\Throttle} again, undoing {@see self::preventThrottling()}. + */ + public function allowThrottling(): self + { + $this->container->get(RateLimitConfig::class)->enabled = true; + + return $this; + } + + /** + * Records attempts against the specified rate limit, as though a client had made them. The window + * is incremented once by `$times`, since only the first attempt decides when the window ends. + */ + public function hit(RateLimit $limit, int $times = 1): self + { + $this->limiter()->attempt($limit, by: $times); + + return $this; + } + + /** + * Records as many attempts as the specified rate limit allows, leaving it with no allowance left. + */ + public function exhaust(RateLimit $limit): self + { + return $this->hit($limit, $limit->attempts); + } + + /** + * Discards the attempts recorded for the specified rate limit. + */ + public function clear(RateLimit $limit): self + { + $this->limiter()->clear($limit); + + return $this; + } + + /** + * Asserts that the specified rate limit has no allowance left. + */ + public function assertThrottled(RateLimit $limit): self + { + Assert::assertTrue( + condition: $this->limiter()->peek($limit)->exceeded, + message: "The rate limit for `{$limit->key}` was expected to be exceeded, but it was not.", + ); + + return $this; + } + + /** + * Asserts that the specified rate limit still has allowance left. + */ + public function assertNotThrottled(RateLimit $limit): self + { + Assert::assertFalse( + condition: $this->limiter()->peek($limit)->exceeded, + message: "The rate limit for `{$limit->key}` was expected not to be exceeded, but it was.", + ); + + return $this; + } + + /** + * Asserts how many attempts have been recorded against the specified rate limit. + */ + public function assertHits(RateLimit $limit, int $expected): self + { + Assert::assertSame( + expected: $expected, + actual: $hits = $this->limiter()->peek($limit)->hits, + message: "The rate limit for `{$limit->key}` was expected to have {$expected} attempt(s) recorded, {$hits} found.", + ); + + return $this; + } + + /** + * Asserts how many attempts the specified rate limit has left. + */ + public function assertRemaining(RateLimit $limit, int $expected): self + { + Assert::assertSame( + expected: $expected, + actual: $remaining = $this->limiter()->peek($limit)->remaining, + message: "The rate limit for `{$limit->key}` was expected to have {$expected} attempt(s) left, {$remaining} found.", + ); + + return $this; + } + + private function limiter(): RateLimiter + { + return $this->container->get(RateLimiter::class); + } +} diff --git a/packages/rate-limit/src/Testing/TestingRateLimitStorage.php b/packages/rate-limit/src/Testing/TestingRateLimitStorage.php new file mode 100644 index 0000000000..9a6482fa34 --- /dev/null +++ b/packages/rate-limit/src/Testing/TestingRateLimitStorage.php @@ -0,0 +1,51 @@ + */ + private array $states = []; + + public function __construct( + private readonly Clock $clock, + ) {} + + public function find(string $key): ?RateLimitState + { + $state = $this->states[$key] ?? null; + + if ($state === null) { + return null; + } + + if ($state->resetsAtInSeconds <= $this->clock->seconds()) { + unset($this->states[$key]); + + return null; + } + + return $state; + } + + public function increment(string $key, Duration $window, int $by = 1): RateLimitState + { + return $this->states[$key] = $this->find($key)?->incrementedBy($by) ?? RateLimitState::opening($this->clock, $window, hits: $by); + } + + public function remove(string $key): void + { + unset($this->states[$key]); + } +} diff --git a/packages/rate-limit/tests/RateLimiterTest.php b/packages/rate-limit/tests/RateLimiterTest.php new file mode 100644 index 0000000000..b81875c5a7 --- /dev/null +++ b/packages/rate-limit/tests/RateLimiterTest.php @@ -0,0 +1,219 @@ +clock = new MockClock('2026-01-01 00:00:00'); + + $this->limiter = new GenericRateLimiter( + storage: new CacheRateLimitStorage( + cache: new GenericCache(new ArrayAdapter(clock: $this->clock->toPsrClock())), + clock: $this->clock, + config: new RateLimitConfig(), + ), + clock: $this->clock, + ); + } + + #[Test] + public function allows_attempts_up_to_the_limit(): void + { + $limit = RateLimit::perMinute(3)->withKey('user:1'); + + $this->assertTrue($this->limiter->attempt($limit)->allowed); + $this->assertTrue($this->limiter->attempt($limit)->allowed); + + $third = $this->limiter->attempt($limit); + + $this->assertTrue($third->allowed); + $this->assertSame(0, $third->remaining); + + $this->assertTrue($this->limiter->attempt($limit)->exceeded); + } + + #[Test] + public function counts_down_the_remaining_attempts(): void + { + $limit = RateLimit::perMinute(3)->withKey('user:1'); + + $this->assertSame(3, $this->limiter->peek($limit)->remaining); + $this->assertSame(2, $this->limiter->attempt($limit)->remaining); + $this->assertSame(1, $this->limiter->attempt($limit)->remaining); + $this->assertSame(1, $this->limiter->peek($limit)->remaining); + } + + #[Test] + public function peeking_does_not_consume_an_attempt(): void + { + $limit = RateLimit::perMinute(1)->withKey('user:1'); + + $this->assertFalse($this->limiter->peek($limit)->exceeded); + $this->assertFalse($this->limiter->peek($limit)->exceeded); + + $this->limiter->attempt($limit); + + $this->assertTrue($this->limiter->peek($limit)->exceeded); + } + + #[Test] + public function keys_do_not_share_a_counter(): void + { + $limit = RateLimit::perMinute(1); + + $this->assertTrue($this->limiter->attempt($limit->withKey('user:1'))->allowed); + $this->assertTrue($this->limiter->attempt($limit->withKey('user:2'))->allowed); + $this->assertTrue($this->limiter->attempt($limit->withKey('user:1'))->exceeded); + } + + #[Test] + public function the_window_reopens_once_it_has_elapsed(): void + { + $limit = RateLimit::perMinute(1)->withKey('user:1'); + + $this->assertTrue($this->limiter->attempt($limit)->allowed); + $this->assertTrue($this->limiter->attempt($limit)->exceeded); + + $this->clock->sleep(Duration::seconds(61)); + + $this->assertTrue($this->limiter->attempt($limit)->allowed); + } + + #[Test] + public function exceeding_the_limit_does_not_extend_the_window(): void + { + $limit = RateLimit::perMinute(1)->withKey('user:1'); + + $this->limiter->attempt($limit); + $resetsAt = $this->limiter->peek($limit)->resetsAtInSeconds; + + $this->clock->sleep(Duration::seconds(30)); + $this->limiter->attempt($limit); + + $this->assertSame($resetsAt, $this->limiter->peek($limit)->resetsAtInSeconds); + } + + #[Test] + public function reports_how_long_to_wait(): void + { + $limit = RateLimit::perMinute(1)->withKey('user:1'); + + $this->limiter->attempt($limit); + $this->clock->sleep(Duration::seconds(20)); + + $this->assertSame(40, $this->limiter->attempt($limit)->retryAfterInSeconds); + } + + #[Test] + public function clearing_discards_the_recorded_attempts(): void + { + $limit = RateLimit::perMinute(1)->withKey('user:1'); + + $this->limiter->attempt($limit); + $this->assertTrue($this->limiter->peek($limit)->exceeded); + + $this->limiter->clear($limit); + + $this->assertFalse($this->limiter->peek($limit)->exceeded); + } + + #[Test] + public function throttling_executes_the_callback_until_the_limit_is_reached(): void + { + $limit = RateLimit::perMinute(1)->withKey('user:1'); + + $this->assertSame('executed', $this->limiter->throttle($limit, fn () => 'executed')); + + $this->expectException(RateLimitWasExceeded::class); + + $this->limiter->throttle($limit, fn () => 'executed'); + } + + #[Test] + public function attempts_may_be_consumed_in_bulk(): void + { + $limit = RateLimit::perMinute(10)->withKey('user:1'); + + $this->assertSame(6, $this->limiter->attempt($limit, by: 4)->remaining); + $this->assertTrue($this->limiter->attempt($limit, by: 7)->exceeded); + } + + #[Test] + public function an_allowed_attempt_has_nothing_to_wait_for(): void + { + $limit = RateLimit::perMinute(2)->withKey('user:1'); + + $this->assertSame(0, $this->limiter->peek($limit)->retryAfterInSeconds); + $this->assertSame(0, $this->limiter->attempt($limit)->retryAfterInSeconds); + } + + #[Test] + public function a_limit_without_a_key_is_rejected(): void + { + $this->expectException(RateLimitHasNoKey::class); + + $this->limiter->attempt(RateLimit::perMinute(1)); + } + + #[Test] + public function windows_are_expressed_in_any_unit(): void + { + $this->assertSame(1.0, RateLimit::perSecond(1)->window->getTotalSeconds()); + $this->assertSame(300.0, RateLimit::perMinute(1, minutes: 5)->window->getTotalSeconds()); + $this->assertSame(3600.0, RateLimit::perHour(1)->window->getTotalSeconds()); + $this->assertSame(86_400.0, Per::DAY->toDuration()->getTotalSeconds()); + } + + #[Test] + public function peeking_at_an_untouched_limit_reports_no_open_window(): void + { + $result = $this->limiter->peek(RateLimit::perMinute(3)->withKey('user:1')); + + $this->assertTrue($result->allowed); + $this->assertSame(0, $result->hits); + $this->assertSame(3, $result->remaining); + + // Nothing has been counted yet. No window may be reported as running. + $this->assertSame($this->clock->seconds(), $result->resetsAtInSeconds); + $this->assertSame(0, $result->retryAfterInSeconds); + } + + #[Test] + public function scoping_appends_to_the_key_a_limit_already_has(): void + { + $limit = RateLimit::perMinute(3)->withKey('login'); + + $this->assertSame('login:user:1', $limit->scopedTo('user:1')->key); + + // Scoping a keyless limit has nothing to append to, and names it outright. + $this->assertSame('user:1', RateLimit::perMinute(3)->scopedTo('user:1')->key); + } +} diff --git a/packages/rate-limit/tests/ThrottleTest.php b/packages/rate-limit/tests/ThrottleTest.php new file mode 100644 index 0000000000..6b138e69b8 --- /dev/null +++ b/packages/rate-limit/tests/ThrottleTest.php @@ -0,0 +1,49 @@ +toRateLimit(); + + $this->assertSame(10, $limit->attempts); + $this->assertSame(300.0, $limit->window->getTotalSeconds()); + } + + #[Test] + public function the_window_defaults_to_a_single_minute(): void + { + $limit = new Throttle(attempts: 10)->toRateLimit(); + + $this->assertSame(60.0, $limit->window->getTotalSeconds()); + } + + #[Test] + public function a_named_bucket_becomes_the_limits_key(): void + { + $limit = new Throttle(attempts: 10, bucket: 'api')->toRateLimit(); + + $this->assertSame('api', $limit->key); + } + + #[Test] + public function an_unnamed_bucket_leaves_the_limit_unkeyed(): void + { + $limit = new Throttle(attempts: 10)->toRateLimit(); + + $this->assertNull($limit->key); + } +} diff --git a/src/Tempest/Framework/Testing/IntegrationTest.php b/src/Tempest/Framework/Testing/IntegrationTest.php index f755385332..cffad41787 100644 --- a/src/Tempest/Framework/Testing/IntegrationTest.php +++ b/src/Tempest/Framework/Testing/IntegrationTest.php @@ -34,6 +34,7 @@ use Tempest\Mail\Testing\TestingMailer; use Tempest\Mcp\Testing\McpTester; use Tempest\Process\Testing\ProcessTester; +use Tempest\RateLimit\Testing\RateLimitTester; use Tempest\Storage\Testing\StorageTester; use Throwable; @@ -124,6 +125,11 @@ abstract class IntegrationTest extends TestCase */ protected McpTester $mcp; + /** + * Provides utilities for testing rate limits. + */ + protected RateLimitTester $rateLimit; + protected function setUp(): void { parent::setUp(); @@ -205,6 +211,7 @@ protected function setupTesters(): self $this->database = new DatabaseTester($this->container); $this->view = new ViewTester($this->container); $this->mcp = new McpTester($this->container); + $this->rateLimit = new RateLimitTester($this->container); return $this; } diff --git a/tests/Fixtures/Controllers/ClassThrottledController.php b/tests/Fixtures/Controllers/ClassThrottledController.php new file mode 100644 index 0000000000..5d10b42d0c --- /dev/null +++ b/tests/Fixtures/Controllers/ClassThrottledController.php @@ -0,0 +1,32 @@ +headers->get('x-api-key') === 'premium') { + return []; + } + + return [RateLimit::perMinute(1)]; + } +} diff --git a/tests/Fixtures/RateLimit/TieredRateLimitProfile.php b/tests/Fixtures/RateLimit/TieredRateLimitProfile.php new file mode 100644 index 0000000000..4c26596459 --- /dev/null +++ b/tests/Fixtures/RateLimit/TieredRateLimitProfile.php @@ -0,0 +1,23 @@ +clock = $this->clock('2025-08-02 12:00:00'); + $this->rateLimit->fake(); + } + + #[Test] + public function attempts_are_counted_without_a_cache_or_a_redis_server(): void + { + $limit = RateLimit::perMinute(3)->withKey('login'); + + $this->rateLimit + ->assertNotThrottled($limit) + ->hit($limit, times: 2) + ->assertHits($limit, 2) + ->assertRemaining($limit, 1) + ->assertNotThrottled($limit); + } + + #[Test] + public function a_limit_may_be_exhausted_and_cleared(): void + { + $limit = RateLimit::perMinute(3)->withKey('login'); + + $this->rateLimit + ->exhaust($limit) + ->assertThrottled($limit) + ->clear($limit) + ->assertNotThrottled($limit) + ->assertHits($limit, 0); + } + + #[Test] + public function counters_are_kept_apart_per_key(): void + { + $login = RateLimit::perMinute(1)->withKey('login'); + $signup = RateLimit::perMinute(1)->withKey('signup'); + + $this->rateLimit + ->exhaust($login) + ->assertThrottled($login) + ->assertNotThrottled($signup); + } + + #[Test] + public function clearing_a_limit_leaves_the_other_keys_alone(): void + { + $login = RateLimit::perMinute(1)->withKey('login'); + $signup = RateLimit::perMinute(1)->withKey('signup'); + + $this->rateLimit + ->exhaust($login) + ->exhaust($signup) + ->clear($login) + ->assertNotThrottled($login) + ->assertThrottled($signup); + } + + #[Test] + public function faking_again_discards_every_recorded_attempt(): void + { + $limit = RateLimit::perMinute(3)->withKey('login'); + + $this->rateLimit->exhaust($limit)->assertThrottled($limit); + + $this->rateLimit->fake()->assertNotThrottled($limit)->assertHits($limit, 0); + } + + #[Test] + public function a_window_closes_once_the_clock_moves_past_it(): void + { + $limit = RateLimit::perMinute(1)->withKey('login'); + + $this->rateLimit->exhaust($limit)->assertThrottled($limit); + + $this->clock->plus(Duration::minutes(2)); + $this->rateLimit->assertNotThrottled($limit); + } +} diff --git a/tests/Integration/RateLimit/RedisRateLimitStorageTest.php b/tests/Integration/RateLimit/RedisRateLimitStorageTest.php new file mode 100644 index 0000000000..07a98bb0b2 --- /dev/null +++ b/tests/Integration/RateLimit/RedisRateLimitStorageTest.php @@ -0,0 +1,131 @@ +eventBus->preventEventHandling(); + + $this->container->config(new RedisConfig( + prefix: 'tempest_test:', + // Cleaning up flushes the database, so this suite keeps one to itself. The other Redis + // suites share database 6, and in parallel they would flush each other's keys mid-test. + database: 7, + connectionTimeOut: .2, + )); + + $this->redis = $this->container->get(Redis::class); + + try { + $this->redis->connect(); + } catch (Throwable) { + $this->markTestSkipped('Could not connect to Redis.'); + } + + $this->rateLimitStorage = $this->container->get(RedisRateLimitStorage::class); + } + + #[PostCondition] + protected function cleanup(): void + { + try { + $this->redis->flush(); + } catch (Throwable) { // @mago-expect lint:no-empty-catch-clause + } + } + + #[Test] + public function no_window_is_open_until_the_first_attempt(): void + { + $this->assertNull($this->rateLimitStorage->find('a')); + } + + #[Test] + public function attempts_accumulate_within_a_window(): void + { + $this->assertSame(1, $this->rateLimitStorage->increment('a', Duration::minute())->hits); + $this->assertSame(2, $this->rateLimitStorage->increment('a', Duration::minute())->hits); + $this->assertSame(5, $this->rateLimitStorage->increment('a', Duration::minute(), by: 3)->hits); + + $this->assertSame(5, $this->rateLimitStorage->find('a')->hits); + } + + #[Test] + public function counters_are_scoped_per_key(): void + { + $this->rateLimitStorage->increment('a', Duration::minute()); + $this->rateLimitStorage->increment('b', Duration::minute()); + $this->rateLimitStorage->increment('b', Duration::minute()); + + $this->assertSame(1, $this->rateLimitStorage->find('a')->hits); + $this->assertSame(2, $this->rateLimitStorage->find('b')->hits); + } + + #[Test] + public function the_window_is_opened_by_the_first_attempt_and_not_extended_by_later_ones(): void + { + $opened = $this->rateLimitStorage->increment('a', Duration::minutes(10)); + + // A later attempt within the same window must not push the reset further away. + $later = $this->rateLimitStorage->increment('a', Duration::minutes(10)); + + $this->assertSame($opened->resetsAtInSeconds, $later->resetsAtInSeconds); + } + + #[Test] + public function the_window_expires_on_its_own(): void + { + $state = $this->rateLimitStorage->increment('a', Duration::seconds(1)); + + $this->assertSame(1, $state->hits); + + // The counter carries a time to live, so it disappears without anyone removing it. + sleep(2); + + $this->assertNull($this->rateLimitStorage->find('a')); + $this->assertSame(1, $this->rateLimitStorage->increment('a', Duration::seconds(1))->hits); + } + + #[Test] + public function removing_a_key_discards_its_window(): void + { + $this->rateLimitStorage->increment('a', Duration::minute()); + $this->rateLimitStorage->increment('a', Duration::minute()); + + $this->rateLimitStorage->remove('a'); + + $this->assertNull($this->rateLimitStorage->find('a')); + $this->assertSame(1, $this->rateLimitStorage->increment('a', Duration::minute())->hits); + } + + #[Test] + public function removing_a_key_that_was_never_incremented_is_harmless(): void + { + $this->rateLimitStorage->remove('a'); + + $this->assertNull($this->rateLimitStorage->find('a')); + } +} diff --git a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php new file mode 100644 index 0000000000..59fafac22e --- /dev/null +++ b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php @@ -0,0 +1,303 @@ +rateLimit->fake(); + } + + #[Test] + public function requests_are_allowed_up_to_the_declared_limit(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function routes_without_the_attribute_are_not_throttled(): void + { + foreach (range(1, 5) as $ignored) { + $this->http->fromIp('203.0.113.9')->get('/not-throttled')->assertOk(); + } + } + + #[Test] + public function counters_are_scoped_per_client(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + + $this->http->fromIp('198.51.100.7')->get('/throttled')->assertOk(); + } + + #[Test] + public function counters_are_shared_between_spellings_of_the_same_address(): void + { + $this->http->fromIp('127.0.0.1')->get('/throttled')->assertOk(); + $this->http->fromIp('::ffff:127.0.0.1')->get('/throttled')->assertOk(); + + $this->http->fromIp('127.0.0.1')->get('/throttled')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function unidentified_clients_share_a_single_counter(): void + { + $this->container->config(new RateLimitConfig(keyResolverClass: UnidentifiedKeyResolver::class)); + + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + $this->http->fromIp('198.51.100.7')->get('/throttled')->assertOk(); + + // Neither client could be identified, so the limit is reached despite the differing addresses. + $this->http->fromIp('192.0.2.1')->get('/throttled')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function counters_are_scoped_per_route(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + + $this->http->fromIp('203.0.113.9')->get('/throttled-twice')->assertOk(); + } + + #[Test] + public function responses_carry_the_remaining_allowance(): void + { + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled') + ->assertOk() + ->assertHeaderContains('x-ratelimit-limit', '2') + ->assertHeaderContains('x-ratelimit-remaining', '1'); + + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled') + ->assertHeaderContains('x-ratelimit-remaining', '0'); + } + + #[Test] + public function throttled_responses_say_when_to_retry(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled'); + $this->http->fromIp('203.0.113.9')->get('/throttled'); + + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled') + ->assertStatus(Status::TOO_MANY_REQUESTS) + ->assertHasHeader('retry-after') + ->assertHeaderContains('x-ratelimit-remaining', '0'); + } + + #[Test] + public function headers_may_be_disabled(): void + { + $this->container->config(new RateLimitConfig(includeHeaders: false)); + + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled') + ->assertOk() + ->assertDoesNotHaveHeader('x-ratelimit-limit'); + + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + + // `includeHeaders` governs the allowance headers only. A 429 still carries `retry-after`, + // without which a client has no way of knowing when to come back. + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled') + ->assertStatus(Status::TOO_MANY_REQUESTS) + ->assertHasHeader('retry-after') + ->assertDoesNotHaveHeader('x-ratelimit-limit'); + } + + #[Test] + public function the_narrowest_of_several_limits_is_reported(): void + { + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled-twice') + ->assertOk() + ->assertHeaderContains('x-ratelimit-limit', '1') + ->assertHeaderContains('x-ratelimit-remaining', '0'); + + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled-twice') + ->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function a_profile_resolves_the_limits_from_the_request(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled-by-profile')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-by-profile')->assertStatus(Status::TOO_MANY_REQUESTS); + + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled-by-profile', headers: ['X-Api-Key' => 'premium']) + ->assertOk(); + } + + #[Test] + public function limits_returned_by_a_profile_get_a_counter_each(): void + { + // The profile returns three per minute and one per day. Each gets its own counter, so the + // first request spends one of each. Sharing a counter would spend it twice, rejecting the + // first request against the daily limit. + $this->http->fromIp('203.0.113.9')->get('/throttled-by-tiers')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-by-tiers')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function limits_sharing_a_window_get_a_counter_each(): void + { + // Both attributes describe a one minute window, so neither may derive its key from it. + $this->http->fromIp('203.0.113.9')->get('/throttled-by-two-identical-windows')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-by-two-identical-windows')->assertOk(); + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled-by-two-identical-windows') + ->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function a_rejected_request_does_not_burn_the_wider_windows(): void + { + $clock = $this->clock('2026-01-01 00:00:00'); + + // Storage captures the clock when it's faked, so it has to be faked again against this one. + $this->rateLimit->fake(); + + // The route allows two requests per minute and three per day, in that declaration order. + $this->http->fromIp('203.0.113.9')->get('/throttled-widest-first')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-widest-first')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-widest-first')->assertStatus(Status::TOO_MANY_REQUESTS); + $this->http->fromIp('203.0.113.9')->get('/throttled-widest-first')->assertStatus(Status::TOO_MANY_REQUESTS); + + $clock->sleep(Duration::seconds(61)); + + // The rejected requests cost nothing, so one of the three daily attempts is still left. + $this->http->fromIp('203.0.113.9')->get('/throttled-widest-first')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-widest-first')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function a_limit_declared_on_the_controller_covers_every_route_it_exposes(): void + { + $this->http->fromIp('203.0.113.9')->get('/class-throttled/second')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/class-throttled/second')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/class-throttled/second')->assertOk(); + + // The controller allows three requests per hour in total, so the other route is out of allowance too. + $this->http->fromIp('203.0.113.9')->get('/class-throttled/first')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function a_route_may_narrow_the_limit_declared_on_its_controller(): void + { + $this->http->fromIp('203.0.113.9')->get('/class-throttled/first')->assertOk(); + + // The route allows one request per minute, well within the controller's hourly allowance. + $this->http->fromIp('203.0.113.9')->get('/class-throttled/first')->assertStatus(Status::TOO_MANY_REQUESTS); + $this->http->fromIp('203.0.113.9')->get('/class-throttled/second')->assertOk(); + } + + #[Test] + public function a_rejected_request_does_not_consume_the_limits_behind_the_one_it_hit(): void + { + $this->http->fromIp('203.0.113.9')->get('/class-throttled/first')->assertOk(); + + // The route's own limit is exhausted, so these never reach the controller's hourly allowance. + $this->http->fromIp('203.0.113.9')->get('/class-throttled/first')->assertStatus(Status::TOO_MANY_REQUESTS); + $this->http->fromIp('203.0.113.9')->get('/class-throttled/first')->assertStatus(Status::TOO_MANY_REQUESTS); + + $this->http->fromIp('203.0.113.9')->get('/class-throttled/second')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/class-throttled/second')->assertOk(); + } + + #[Test] + public function throttling_may_be_turned_off_entirely(): void + { + $this->rateLimit->preventThrottling(); + + foreach (range(1, 5) as $ignored) { + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + } + + $this->rateLimit->allowThrottling(); + + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function limits_describing_the_same_allowance_describe_one_limit(): void + { + // Both attributes allow two requests per minute, which is one allowance declared twice. It's + // spent once per request, so the route behaves as though one had been declared. + $this->http->fromIp('203.0.113.9')->get('/throttled-by-two-identical-limits')->assertOk(); + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled-by-two-identical-limits') + ->assertOk() + ->assertHeaderContains('x-ratelimit-remaining', '0'); + + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled-by-two-identical-limits') + ->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function routes_naming_the_same_bucket_share_an_allowance(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/first')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/second')->assertOk(); + + // The bucket allows two requests in total, whichever of the two routes they are made against. + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled-by-shared-bucket/first') + ->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function a_shared_bucket_is_still_scoped_per_client(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/first')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/second')->assertOk(); + + $this->http->fromIp('198.51.100.7')->get('/throttled-by-shared-bucket/first')->assertOk(); + } + + #[Test] + public function requests_without_an_address_share_a_single_bucket(): void + { + $this->http->get('/throttled')->assertOk(); + $this->http->get('/throttled')->assertOk(); + $this->http->get('/throttled')->assertStatus(Status::TOO_MANY_REQUESTS); + } +} From 33c09cb1bbc68a7cc5dd4e2b9fe157997d2b5e55 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Fri, 4 Sep 2026 22:45:35 +0100 Subject: [PATCH 2/5] refactor(rate-limit): make RateLimitConfig an interface --- docs/2-features/21-rate-limiting.md | 19 +++-- .../src/Config/CacheRateLimitConfig.php | 60 ++++++++++++++++ .../rate-limit/src/Config/RateLimitConfig.php | 71 ++++++++----------- .../src/Config/RedisRateLimitConfig.php | 54 ++++++++++++++ .../src/Config/rateLimit.config.php | 4 +- .../RateLimitStorageInitializer.php | 2 +- .../src/Storage/CacheRateLimitStorage.php | 4 +- .../src/Storage/RedisRateLimitStorage.php | 4 +- packages/rate-limit/tests/RateLimiterTest.php | 4 +- .../RateLimit/RedisRateLimitStorageTest.php | 4 +- .../RateLimit/ThrottleMiddlewareTest.php | 6 +- 11 files changed, 165 insertions(+), 67 deletions(-) create mode 100644 packages/rate-limit/src/Config/CacheRateLimitConfig.php create mode 100644 packages/rate-limit/src/Config/RedisRateLimitConfig.php diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index 49fbcc58e7..4c0c2b3c48 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -61,7 +61,7 @@ X-RateLimit-Reset: 1767225600 Exceeded limits return a `429 Too Many Requests` status paired with a `Retry-After` header. -Because rate limit headers represent a single client's unique usage, responses carrying them should not be shared via proxy caches. Disable headers entirely by setting `includeHeaders: false` in {b`Tempest\RateLimit\Config\RateLimitConfig`}, or turn off throttling completely during development via `enabled: false`. +Because rate limit headers represent a single client's unique usage, responses carrying them should not be shared via proxy caches. Disable headers entirely by setting `includeHeaders: false` in your rate limit configuration, or turn off throttling completely during development via `enabled: false`. ### Multiple limits @@ -103,9 +103,9 @@ final readonly class ApiKeyResolver implements RateLimitKeyResolver Register your resolver in the configuration: ```php app/rateLimit.config.php -use Tempest\RateLimit\Config\RateLimitConfig; +use Tempest\RateLimit\Config\CacheRateLimitConfig; -return new RateLimitConfig( +return new CacheRateLimitConfig( keyResolverClass: ApiKeyResolver::class, ); @@ -212,21 +212,18 @@ Exceeding limits via `throttle()` throws {b`Tempest\RateLimit\RateLimitWasExceed ## Storage -Windows are managed via {b`Tempest\RateLimit\RateLimitStorage`}. Tempest defaults to {b`Tempest\RateLimit\Storage\CacheRateLimitStorage`}, which requires no external services beyond a standard [cache](./06-cache.md). Because it serialises updates using locks rather than atomic operations, concurrent loads may lead to undercounting. It is also only as durable as the cache itself—when the cache is disabled, no counter is persisted and no limit is ever reached. +Windows are managed via {b`Tempest\RateLimit\RateLimitStorage`}, which is built by the configured {b`Tempest\RateLimit\Config\RateLimitConfig`}. Tempest defaults to {b`Tempest\RateLimit\Config\CacheRateLimitConfig`}, which requires no external services beyond a standard [cache](./06-cache.md). Because it serialises updates using locks rather than atomic operations, concurrent loads may lead to undercounting. It is also only as durable as the cache itself—when the cache is disabled, no counter is persisted and no limit is ever reached. -For high-concurrency production environments, switch to {b`Tempest\RateLimit\Storage\RedisRateLimitStorage`} to utilize atomic Lua-script increments: +For high-concurrency production environments, switch to {b`Tempest\RateLimit\Config\RedisRateLimitConfig`}, which stores windows in Redis using atomic Lua-script increments: ```php app/rateLimit.config.php -use Tempest\RateLimit\Config\RateLimitConfig; -use Tempest\RateLimit\Storage\RedisRateLimitStorage; +use Tempest\RateLimit\Config\RedisRateLimitConfig; -return new RateLimitConfig( - storageClass: RedisRateLimitStorage::class, -); +return new RedisRateLimitConfig(); ``` -Custom storage engines can be integrated by pointing `storageClass` to any custom implementation of the storage interface. +Custom storage engines can be integrated by implementing {b`Tempest\RateLimit\Config\RateLimitConfig`} and returning your own {b`Tempest\RateLimit\RateLimitStorage`} from `createStorage()`. ## Testing diff --git a/packages/rate-limit/src/Config/CacheRateLimitConfig.php b/packages/rate-limit/src/Config/CacheRateLimitConfig.php new file mode 100644 index 0000000000..599bb03331 --- /dev/null +++ b/packages/rate-limit/src/Config/CacheRateLimitConfig.php @@ -0,0 +1,60 @@ + */ + public string $keyResolverClass = ClientIpKeyResolver::class, + ) {} + + public function storageKey(string $key): string + { + return $this->keyPrefix . '_' . hash('xxh128', $key); + } + + public function createStorage(Container $container): CacheRateLimitStorage + { + return new CacheRateLimitStorage( + cache: $container->get(Cache::class), + clock: $container->get(Clock::class), + config: $this, + ); + } +} diff --git a/packages/rate-limit/src/Config/RateLimitConfig.php b/packages/rate-limit/src/Config/RateLimitConfig.php index 67080ec422..93f99aad73 100644 --- a/packages/rate-limit/src/Config/RateLimitConfig.php +++ b/packages/rate-limit/src/Config/RateLimitConfig.php @@ -4,55 +4,42 @@ namespace Tempest\RateLimit\Config; -use Tempest\RateLimit\Http\ClientIpKeyResolver; +use Tempest\Container\Container; use Tempest\RateLimit\Http\RateLimitKeyResolver; use Tempest\RateLimit\RateLimitStorage; -use Tempest\RateLimit\Storage\CacheRateLimitStorage; -final class RateLimitConfig +interface RateLimitConfig { - public function __construct( - /** - * Whether `#[Throttle]` applies the limits it declares. Limits consumed directly through - * {@see \Tempest\RateLimit\RateLimiter} are not affected. - */ - public bool $enabled = true, - - /** - * Prefix used for the keys under which rate limit windows are stored. - */ - public string $keyPrefix = 'rate_limit', - - /** - * Lock timeout for concurrent updates. Used by {@see \Tempest\RateLimit\Storage\CacheRateLimitStorage}. - */ - public int $lockTimeoutInSeconds = 5, - - /** - * Whether HTTP responses include `X-RateLimit-*` headers. These headers are per-client and must - * not be cached by a shared proxy. - */ - public bool $includeHeaders = true, - - /** - * Storage for rate limit counters. The default works anywhere a cache is configured, but takes a - * lock on every increment. {@see \Tempest\RateLimit\Storage\RedisRateLimitStorage} counts - * atomically and is recommended in production. - * - * @var class-string - */ - public string $storageClass = CacheRateLimitStorage::class, - - /** @var class-string */ - public string $keyResolverClass = ClientIpKeyResolver::class, - ) {} + /** + * Whether `#[Throttle]` applies the limits it declares. Limits consumed directly through + * {@see \Tempest\RateLimit\RateLimiter} are not affected. + */ + public bool $enabled { get; set; } + + /** + * Prefix used for the keys under which rate limit windows are stored. + */ + public string $keyPrefix { get; } + + /** + * Whether HTTP responses include `X-RateLimit-*` headers. These headers are per-client and must + * not be cached by a shared proxy. + */ + public bool $includeHeaders { get; } + + /** + * @var class-string + */ + public string $keyResolverClass { get; } /** * Returns the key a rate limit's window is stored under. Keys are hashed, since a limit may be * scoped to arbitrary input that the store would not accept as a key. */ - public function storageKey(string $key): string - { - return $this->keyPrefix . '_' . hash('xxh128', $key); - } + public function storageKey(string $key): string; + + /** + * Creates the storage in which rate limit windows are kept. + */ + public function createStorage(Container $container): RateLimitStorage; } diff --git a/packages/rate-limit/src/Config/RedisRateLimitConfig.php b/packages/rate-limit/src/Config/RedisRateLimitConfig.php new file mode 100644 index 0000000000..fc92560912 --- /dev/null +++ b/packages/rate-limit/src/Config/RedisRateLimitConfig.php @@ -0,0 +1,54 @@ + */ + public string $keyResolverClass = ClientIpKeyResolver::class, + ) {} + + public function storageKey(string $key): string + { + return $this->keyPrefix . '_' . hash('xxh128', $key); + } + + public function createStorage(Container $container): RedisRateLimitStorage + { + return new RedisRateLimitStorage( + redis: $container->get(Redis::class), + clock: $container->get(Clock::class), + config: $this, + ); + } +} diff --git a/packages/rate-limit/src/Config/rateLimit.config.php b/packages/rate-limit/src/Config/rateLimit.config.php index c425b8ac43..c48f8671cc 100644 --- a/packages/rate-limit/src/Config/rateLimit.config.php +++ b/packages/rate-limit/src/Config/rateLimit.config.php @@ -2,6 +2,6 @@ declare(strict_types=1); -use Tempest\RateLimit\Config\RateLimitConfig; +use Tempest\RateLimit\Config\CacheRateLimitConfig; -return new RateLimitConfig(); +return new CacheRateLimitConfig(); diff --git a/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php b/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php index 9c8ff74a55..4b9f312d21 100644 --- a/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php +++ b/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php @@ -15,6 +15,6 @@ #[Singleton] public function initialize(Container $container): RateLimitStorage { - return $container->get($container->get(RateLimitConfig::class)->storageClass); + return $container->get(RateLimitConfig::class)->createStorage($container); } } diff --git a/packages/rate-limit/src/Storage/CacheRateLimitStorage.php b/packages/rate-limit/src/Storage/CacheRateLimitStorage.php index 1f7e560947..8cff6adf2d 100644 --- a/packages/rate-limit/src/Storage/CacheRateLimitStorage.php +++ b/packages/rate-limit/src/Storage/CacheRateLimitStorage.php @@ -7,7 +7,7 @@ use Tempest\Cache\Cache; use Tempest\Clock\Clock; use Tempest\DateTime\Duration; -use Tempest\RateLimit\Config\RateLimitConfig; +use Tempest\RateLimit\Config\CacheRateLimitConfig; use Tempest\RateLimit\RateLimitStorage; /** @@ -18,7 +18,7 @@ public function __construct( private Cache $cache, private Clock $clock, - private RateLimitConfig $config, + private CacheRateLimitConfig $config, ) {} public function find(string $key): ?RateLimitState diff --git a/packages/rate-limit/src/Storage/RedisRateLimitStorage.php b/packages/rate-limit/src/Storage/RedisRateLimitStorage.php index c9cb1c86c7..4110276892 100644 --- a/packages/rate-limit/src/Storage/RedisRateLimitStorage.php +++ b/packages/rate-limit/src/Storage/RedisRateLimitStorage.php @@ -7,7 +7,7 @@ use Tempest\Clock\Clock; use Tempest\DateTime\Duration; use Tempest\KeyValue\Redis\Redis; -use Tempest\RateLimit\Config\RateLimitConfig; +use Tempest\RateLimit\Config\RedisRateLimitConfig; use Tempest\RateLimit\RateLimitStorage; /** @@ -47,7 +47,7 @@ public function __construct( private Redis $redis, private Clock $clock, - private RateLimitConfig $config, + private RedisRateLimitConfig $config, ) {} public function find(string $key): ?RateLimitState diff --git a/packages/rate-limit/tests/RateLimiterTest.php b/packages/rate-limit/tests/RateLimiterTest.php index b81875c5a7..e4b1b9ed63 100644 --- a/packages/rate-limit/tests/RateLimiterTest.php +++ b/packages/rate-limit/tests/RateLimiterTest.php @@ -10,7 +10,7 @@ use Tempest\Cache\GenericCache; use Tempest\Clock\MockClock; use Tempest\DateTime\Duration; -use Tempest\RateLimit\Config\RateLimitConfig; +use Tempest\RateLimit\Config\CacheRateLimitConfig; use Tempest\RateLimit\GenericRateLimiter; use Tempest\RateLimit\Per; use Tempest\RateLimit\RateLimit; @@ -38,7 +38,7 @@ protected function setUp(): void storage: new CacheRateLimitStorage( cache: new GenericCache(new ArrayAdapter(clock: $this->clock->toPsrClock())), clock: $this->clock, - config: new RateLimitConfig(), + config: new CacheRateLimitConfig(), ), clock: $this->clock, ); diff --git a/tests/Integration/RateLimit/RedisRateLimitStorageTest.php b/tests/Integration/RateLimit/RedisRateLimitStorageTest.php index 07a98bb0b2..8af133f7b8 100644 --- a/tests/Integration/RateLimit/RedisRateLimitStorageTest.php +++ b/tests/Integration/RateLimit/RedisRateLimitStorageTest.php @@ -10,8 +10,8 @@ use Tempest\DateTime\Duration; use Tempest\KeyValue\Redis\Config\RedisConfig; use Tempest\KeyValue\Redis\Redis; +use Tempest\RateLimit\Config\RedisRateLimitConfig; use Tempest\RateLimit\RateLimitStorage; -use Tempest\RateLimit\Storage\RedisRateLimitStorage; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Throwable; @@ -45,7 +45,7 @@ protected function configure(): void $this->markTestSkipped('Could not connect to Redis.'); } - $this->rateLimitStorage = $this->container->get(RedisRateLimitStorage::class); + $this->rateLimitStorage = new RedisRateLimitConfig()->createStorage($this->container); } #[PostCondition] diff --git a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php index 59fafac22e..8e135c0314 100644 --- a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php +++ b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\Attributes\Test; use Tempest\DateTime\Duration; use Tempest\Http\Status; -use Tempest\RateLimit\Config\RateLimitConfig; +use Tempest\RateLimit\Config\CacheRateLimitConfig; use Tests\Tempest\Fixtures\RateLimit\UnidentifiedKeyResolver; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -61,7 +61,7 @@ public function counters_are_shared_between_spellings_of_the_same_address(): voi #[Test] public function unidentified_clients_share_a_single_counter(): void { - $this->container->config(new RateLimitConfig(keyResolverClass: UnidentifiedKeyResolver::class)); + $this->container->config(new CacheRateLimitConfig(keyResolverClass: UnidentifiedKeyResolver::class)); $this->http->fromIp('203.0.113.9')->get('/throttled')->assertOk(); $this->http->fromIp('198.51.100.7')->get('/throttled')->assertOk(); @@ -112,7 +112,7 @@ public function throttled_responses_say_when_to_retry(): void #[Test] public function headers_may_be_disabled(): void { - $this->container->config(new RateLimitConfig(includeHeaders: false)); + $this->container->config(new CacheRateLimitConfig(includeHeaders: false)); $this->http ->fromIp('203.0.113.9') From 9ae24682f9188b97da730331796222b121499f4e Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 5 Sep 2026 01:03:30 +0100 Subject: [PATCH 3/5] refactor(rate-limit): conform acronym casing in ClientIPKeyResolver --- docs/2-features/21-rate-limiting.md | 2 +- packages/rate-limit/src/Config/CacheRateLimitConfig.php | 4 ++-- packages/rate-limit/src/Config/RedisRateLimitConfig.php | 4 ++-- .../Http/{ClientIpKeyResolver.php => ClientIPKeyResolver.php} | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename packages/rate-limit/src/Http/{ClientIpKeyResolver.php => ClientIPKeyResolver.php} (88%) diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index 4c0c2b3c48..e1576cf297 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -80,7 +80,7 @@ Limits evaluate sequentially—starting with route-level rules and following up ## Choosing what to count -Requests default to tracking against the client's IP address via {b`Tempest\RateLimit\Http\ClientIpKeyResolver`}, utilizing packed formats so `::ffff:127.0.0.1` and `127.0.0.1` share a single counter. +Requests default to tracking against the client's IP address via {b`Tempest\RateLimit\Http\ClientIPKeyResolver`}, utilizing packed formats so `::ffff:127.0.0.1` and `127.0.0.1` share a single counter. Applications behind a reverse proxy must configure trusted proxies in {b`Tempest\Http\Ip\TrustedProxiesConfig`} (see the [trusted proxies documentation](../1-essentials/01-routing.md#trusted-proxies)). Without this, all incoming proxy requests collapse into a single shared counter. diff --git a/packages/rate-limit/src/Config/CacheRateLimitConfig.php b/packages/rate-limit/src/Config/CacheRateLimitConfig.php index 599bb03331..910ebda250 100644 --- a/packages/rate-limit/src/Config/CacheRateLimitConfig.php +++ b/packages/rate-limit/src/Config/CacheRateLimitConfig.php @@ -7,7 +7,7 @@ use Tempest\Cache\Cache; use Tempest\Clock\Clock; use Tempest\Container\Container; -use Tempest\RateLimit\Http\ClientIpKeyResolver; +use Tempest\RateLimit\Http\ClientIPKeyResolver; use Tempest\RateLimit\Http\RateLimitKeyResolver; use Tempest\RateLimit\Storage\CacheRateLimitStorage; @@ -41,7 +41,7 @@ public function __construct( public bool $includeHeaders = true, /** @var class-string */ - public string $keyResolverClass = ClientIpKeyResolver::class, + public string $keyResolverClass = ClientIPKeyResolver::class, ) {} public function storageKey(string $key): string diff --git a/packages/rate-limit/src/Config/RedisRateLimitConfig.php b/packages/rate-limit/src/Config/RedisRateLimitConfig.php index fc92560912..6d5484d70e 100644 --- a/packages/rate-limit/src/Config/RedisRateLimitConfig.php +++ b/packages/rate-limit/src/Config/RedisRateLimitConfig.php @@ -7,7 +7,7 @@ use Tempest\Clock\Clock; use Tempest\Container\Container; use Tempest\KeyValue\Redis\Redis; -use Tempest\RateLimit\Http\ClientIpKeyResolver; +use Tempest\RateLimit\Http\ClientIPKeyResolver; use Tempest\RateLimit\Http\RateLimitKeyResolver; use Tempest\RateLimit\Storage\RedisRateLimitStorage; @@ -35,7 +35,7 @@ public function __construct( public bool $includeHeaders = true, /** @var class-string */ - public string $keyResolverClass = ClientIpKeyResolver::class, + public string $keyResolverClass = ClientIPKeyResolver::class, ) {} public function storageKey(string $key): string diff --git a/packages/rate-limit/src/Http/ClientIpKeyResolver.php b/packages/rate-limit/src/Http/ClientIPKeyResolver.php similarity index 88% rename from packages/rate-limit/src/Http/ClientIpKeyResolver.php rename to packages/rate-limit/src/Http/ClientIPKeyResolver.php index 6454d20c98..027b302a81 100644 --- a/packages/rate-limit/src/Http/ClientIpKeyResolver.php +++ b/packages/rate-limit/src/Http/ClientIPKeyResolver.php @@ -10,7 +10,7 @@ * Counts requests by client IP. Requires {@see \Tempest\Http\Ip\TrustedProxiesConfig} * for reliable client IPs behind proxies. */ -final readonly class ClientIpKeyResolver implements RateLimitKeyResolver +final readonly class ClientIPKeyResolver implements RateLimitKeyResolver { public function resolve(Request $request): ?string { From a09d24cc86dd8adcdb835cc5399d89e0e34a4711 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 5 Sep 2026 01:59:03 +0100 Subject: [PATCH 4/5] refactor(rate-limit): drop the enabled flag in favor of an unlimited test limiter --- docs/2-features/21-rate-limiting.md | 8 ++- .../src/Config/CacheRateLimitConfig.php | 6 --- .../rate-limit/src/Config/RateLimitConfig.php | 6 --- .../src/Config/RedisRateLimitConfig.php | 6 --- .../src/Http/ThrottleMiddleware.php | 4 -- .../src/Testing/RateLimitTester.php | 19 ++++--- .../src/Testing/UnlimitedRateLimiter.php | 53 +++++++++++++++++++ .../RateLimit/RateLimitTesterTest.php | 30 +++++++++++ .../RateLimit/ThrottleMiddlewareTest.php | 2 +- 9 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 packages/rate-limit/src/Testing/UnlimitedRateLimiter.php diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index e1576cf297..a7f194260a 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -61,7 +61,7 @@ X-RateLimit-Reset: 1767225600 Exceeded limits return a `429 Too Many Requests` status paired with a `Retry-After` header. -Because rate limit headers represent a single client's unique usage, responses carrying them should not be shared via proxy caches. Disable headers entirely by setting `includeHeaders: false` in your rate limit configuration, or turn off throttling completely during development via `enabled: false`. +Because rate limit headers represent a single client's unique usage, responses carrying them should not be shared via proxy caches. Disable headers entirely by setting `includeHeaders: false` in your rate limit configuration. ### Multiple limits @@ -248,13 +248,17 @@ $this->rateLimit Windows expire against the clock, so a mocked clock moved past the end of a window reopens it. Use `clear()` to discard the attempts recorded for a single limit between assertions, or call `fake()` again to discard all of them. -To disable route-level throttling across tests while keeping manual `RateLimiter` calls active, use: +To allow every attempt, leaving throttled routes and manual `RateLimiter` calls unlimited, use: ```php $this->rateLimit->preventThrottling(); ``` +Attempts are not recorded while throttling is prevented, so counters are left exactly as they were when `allowThrottling()` restores enforcement. + +This state lasts for a single test. Call it from `setUp()` to cover an entire test case. + HTTP tests interact with throttled routes naturally through simulated requests: ```php diff --git a/packages/rate-limit/src/Config/CacheRateLimitConfig.php b/packages/rate-limit/src/Config/CacheRateLimitConfig.php index 910ebda250..9dfb48d09e 100644 --- a/packages/rate-limit/src/Config/CacheRateLimitConfig.php +++ b/packages/rate-limit/src/Config/CacheRateLimitConfig.php @@ -18,12 +18,6 @@ final class CacheRateLimitConfig implements RateLimitConfig { public function __construct( - /** - * Whether `#[Throttle]` applies the limits it declares. Limits consumed directly through - * {@see \Tempest\RateLimit\RateLimiter} are not affected. - */ - public bool $enabled = true, - /** * Prefix used for the keys under which rate limit windows are stored. */ diff --git a/packages/rate-limit/src/Config/RateLimitConfig.php b/packages/rate-limit/src/Config/RateLimitConfig.php index 93f99aad73..03fa4b3f10 100644 --- a/packages/rate-limit/src/Config/RateLimitConfig.php +++ b/packages/rate-limit/src/Config/RateLimitConfig.php @@ -10,12 +10,6 @@ interface RateLimitConfig { - /** - * Whether `#[Throttle]` applies the limits it declares. Limits consumed directly through - * {@see \Tempest\RateLimit\RateLimiter} are not affected. - */ - public bool $enabled { get; set; } - /** * Prefix used for the keys under which rate limit windows are stored. */ diff --git a/packages/rate-limit/src/Config/RedisRateLimitConfig.php b/packages/rate-limit/src/Config/RedisRateLimitConfig.php index 6d5484d70e..2994bb77b1 100644 --- a/packages/rate-limit/src/Config/RedisRateLimitConfig.php +++ b/packages/rate-limit/src/Config/RedisRateLimitConfig.php @@ -17,12 +17,6 @@ final class RedisRateLimitConfig implements RateLimitConfig { public function __construct( - /** - * Whether `#[Throttle]` applies the limits it declares. Limits consumed directly through - * {@see \Tempest\RateLimit\RateLimiter} are not affected. - */ - public bool $enabled = true, - /** * Prefix used for the keys under which rate limit windows are stored. */ diff --git a/packages/rate-limit/src/Http/ThrottleMiddleware.php b/packages/rate-limit/src/Http/ThrottleMiddleware.php index 3e525397d2..da634a73d2 100644 --- a/packages/rate-limit/src/Http/ThrottleMiddleware.php +++ b/packages/rate-limit/src/Http/ThrottleMiddleware.php @@ -35,10 +35,6 @@ public function __construct( public function __invoke(Request $request, HttpMiddlewareCallable $next): Response { - if (! $this->config->enabled) { - return $next($request); - } - $limits = $this->resolveLimits($request); if ($limits === []) { diff --git a/packages/rate-limit/src/Testing/RateLimitTester.php b/packages/rate-limit/src/Testing/RateLimitTester.php index fbe0d87713..cef0877c1e 100644 --- a/packages/rate-limit/src/Testing/RateLimitTester.php +++ b/packages/rate-limit/src/Testing/RateLimitTester.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\Assert; use Tempest\Clock\Clock; use Tempest\Container\Container; -use Tempest\RateLimit\Config\RateLimitConfig; use Tempest\RateLimit\GenericRateLimiter; use Tempest\RateLimit\RateLimit; use Tempest\RateLimit\RateLimiter; @@ -41,22 +40,30 @@ public function fake(): self } /** - * Leaves routes decorated with {@see \Tempest\RateLimit\Http\Throttle} unlimited. Limits consumed - * directly through {@see RateLimiter} are not affected. + * Allows every attempt without recording it. Counters are left as they were, so + * {@see self::allowThrottling()} resumes where enforcement stopped. */ public function preventThrottling(): self { - $this->container->get(RateLimitConfig::class)->enabled = false; + $limiter = $this->limiter(); + + if (! $limiter instanceof UnlimitedRateLimiter) { + $this->container->singleton(RateLimiter::class, new UnlimitedRateLimiter($limiter)); + } return $this; } /** - * Applies the limits declared by {@see \Tempest\RateLimit\Http\Throttle} again, undoing {@see self::preventThrottling()}. + * Applies limits again, undoing {@see self::preventThrottling()}. */ public function allowThrottling(): self { - $this->container->get(RateLimitConfig::class)->enabled = true; + $limiter = $this->limiter(); + + if ($limiter instanceof UnlimitedRateLimiter) { + $this->container->singleton(RateLimiter::class, $limiter->limiter); + } return $this; } diff --git a/packages/rate-limit/src/Testing/UnlimitedRateLimiter.php b/packages/rate-limit/src/Testing/UnlimitedRateLimiter.php new file mode 100644 index 0000000000..6daa486f8e --- /dev/null +++ b/packages/rate-limit/src/Testing/UnlimitedRateLimiter.php @@ -0,0 +1,53 @@ +peek($limit); + } + + public function peek(RateLimit $limit): RateLimitResult + { + return $this->allow($this->limiter->peek($limit)); + } + + public function throttle(RateLimit $limit, Closure $callback): mixed + { + return $callback(); + } + + public function clear(RateLimit $limit): void + { + $this->limiter->clear($limit); + } + + private function allow(RateLimitResult $result): RateLimitResult + { + return new RateLimitResult( + key: $result->key, + allowed: true, + limit: $result->limit, + hits: $result->hits, + resetsAtInSeconds: $result->resetsAtInSeconds, + retryAfterInSeconds: 0, + ); + } +} diff --git a/tests/Integration/RateLimit/RateLimitTesterTest.php b/tests/Integration/RateLimit/RateLimitTesterTest.php index dce5520000..c96662c072 100644 --- a/tests/Integration/RateLimit/RateLimitTesterTest.php +++ b/tests/Integration/RateLimit/RateLimitTesterTest.php @@ -8,6 +8,7 @@ use Tempest\Clock\MockClock; use Tempest\DateTime\Duration; use Tempest\RateLimit\RateLimit; +use Tempest\RateLimit\RateLimiter; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -38,6 +39,35 @@ public function attempts_are_counted_without_a_cache_or_a_redis_server(): void ->assertNotThrottled($limit); } + #[Test] + public function preventing_throttling_leaves_limits_untouched(): void + { + $limit = RateLimit::perMinute(3)->withKey('login'); + + $this->rateLimit + ->exhaust($limit) + ->assertThrottled($limit) + ->preventThrottling() + ->hit($limit, times: 10) + ->assertNotThrottled($limit) + ->allowThrottling() + ->assertThrottled($limit) + ->assertHits($limit, 3); + } + + #[Test] + public function preventing_throttling_lets_an_exhausted_limit_run_its_callback(): void + { + $limit = RateLimit::perMinute(1)->withKey('login'); + + $this->rateLimit->exhaust($limit)->preventThrottling(); + + $this->assertSame( + expected: 'executed', + actual: $this->container->get(RateLimiter::class)->throttle($limit, fn () => 'executed'), + ); + } + #[Test] public function a_limit_may_be_exhausted_and_cleared(): void { diff --git a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php index 8e135c0314..2b9ffd5b73 100644 --- a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php +++ b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php @@ -238,7 +238,7 @@ public function a_rejected_request_does_not_consume_the_limits_behind_the_one_it } #[Test] - public function throttling_may_be_turned_off_entirely(): void + public function throttling_may_be_prevented_and_allowed_again(): void { $this->rateLimit->preventThrottling(); From b520fe0fbe13c552a0ca018ddb6d52d580308af6 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 5 Sep 2026 02:51:01 +0100 Subject: [PATCH 5/5] fix(rate-limit): keep throttling prevented when the limiter is rebuilt --- docs/2-features/21-rate-limiting.md | 2 +- .../rate-limit/src/Testing/RateLimitTester.php | 15 ++++++++++++++- .../Integration/RateLimit/RateLimitTesterTest.php | 12 ++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index a7f194260a..db37e02456 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -257,7 +257,7 @@ $this->rateLimit->preventThrottling(); Attempts are not recorded while throttling is prevented, so counters are left exactly as they were when `allowThrottling()` restores enforcement. -This state lasts for a single test. Call it from `setUp()` to cover an entire test case. +This state lasts for a single test. Call it from `setUp()` to cover an entire test case; `fake()` and `preventThrottling()` compose in either order. HTTP tests interact with throttled routes naturally through simulated requests: diff --git a/packages/rate-limit/src/Testing/RateLimitTester.php b/packages/rate-limit/src/Testing/RateLimitTester.php index cef0877c1e..9fbb39b56a 100644 --- a/packages/rate-limit/src/Testing/RateLimitTester.php +++ b/packages/rate-limit/src/Testing/RateLimitTester.php @@ -30,12 +30,20 @@ public function fake(): self $this->container->singleton(RateLimitStorage::class, $storage); - // The limiter holds on to the storage it was built with. It's rebuilt around the new one. + // Read before the rebuild below discards the instance. + $prevented = $this->isThrottlingPrevented(); + + // The limiter holds on to the storage it was built with, so it's rebuilt around the new one. $this->container->singleton(RateLimiter::class, new GenericRateLimiter( storage: $storage, clock: $this->container->get(Clock::class), )); + // Prevention is unrelated to storage, so it carries over. + if ($prevented) { + $this->preventThrottling(); + } + return $this; } @@ -155,4 +163,9 @@ private function limiter(): RateLimiter { return $this->container->get(RateLimiter::class); } + + private function isThrottlingPrevented(): bool + { + return $this->limiter() instanceof UnlimitedRateLimiter; + } } diff --git a/tests/Integration/RateLimit/RateLimitTesterTest.php b/tests/Integration/RateLimit/RateLimitTesterTest.php index c96662c072..b5244e4aa9 100644 --- a/tests/Integration/RateLimit/RateLimitTesterTest.php +++ b/tests/Integration/RateLimit/RateLimitTesterTest.php @@ -55,6 +55,18 @@ public function preventing_throttling_leaves_limits_untouched(): void ->assertHits($limit, 3); } + #[Test] + public function faking_storage_keeps_throttling_prevented(): void + { + $limit = RateLimit::perMinute(1)->withKey('login'); + + // Prevention is commonly set up once for a whole test case, before an individual test fakes + // storage of its own. Swapping storage is unrelated to whether limits are enforced. + $this->rateLimit->preventThrottling()->fake(); + + $this->rateLimit->exhaust($limit)->assertNotThrottled($limit); + } + #[Test] public function preventing_throttling_lets_an_exhausted_limit_run_its_callback(): void {