From 47f4f05f9807a01af5dd33f2a31d2a6cd4405472 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 29 Aug 2026 13:04:43 +0100 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 { From fdb2991a3b5d638a52532d5c87888fe1a64e063d Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 8 Sep 2026 02:26:29 +0200 Subject: [PATCH 06/14] test: add failing tests --- .../SharedBucketThrottledController.php | 21 +++++++++ .../Controllers/ThrottledController.php | 9 ++++ .../RateLimit/ThrottleMiddlewareTest.php | 46 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 tests/Fixtures/Controllers/SharedBucketThrottledController.php diff --git a/tests/Fixtures/Controllers/SharedBucketThrottledController.php b/tests/Fixtures/Controllers/SharedBucketThrottledController.php new file mode 100644 index 0000000000..2594a3ddf1 --- /dev/null +++ b/tests/Fixtures/Controllers/SharedBucketThrottledController.php @@ -0,0 +1,21 @@ +http->get('/throttled')->assertOk(); $this->http->get('/throttled')->assertStatus(Status::TOO_MANY_REQUESTS); } + + #[Test] + public function a_route_may_narrow_its_controllers_shared_bucket_allowance(): void + { + $this->http->fromIp('203.0.113.9')->get('/class-throttled/shared-bucket')->assertOk(); + $this->http + ->fromIp('203.0.113.9') + ->get('/class-throttled/shared-bucket') + ->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function http_methods_on_the_same_handler_have_independent_allowances(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled-by-http-method')->assertOk(); + $this->http->fromIp('203.0.113.9')->post('/throttled-by-http-method')->assertOk(); + + $this->http->fromIp('203.0.113.9')->get('/throttled-by-http-method')->assertStatus(Status::TOO_MANY_REQUESTS); + $this->http->fromIp('203.0.113.9')->post('/throttled-by-http-method')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function a_named_http_bucket_can_be_inspected_through_the_limiter(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/first')->assertOk(); + + // The rate-limiting documentation promises direct access by naming the HTTP bucket. + $result = $this->container->get(RateLimiter::class)->peek(RateLimit::perMinute(2)->withKey('shared')); + + $this->assertSame(1, $result->hits); + $this->assertSame(1, $result->remaining); + } + + #[Test] + public function a_named_http_bucket_can_be_cleared_through_the_limiter(): 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('203.0.113.9')->get('/throttled-by-shared-bucket/first')->assertStatus(Status::TOO_MANY_REQUESTS); + + $this->container->get(RateLimiter::class)->clear(RateLimit::perMinute(2)->withKey('shared')); + + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/first')->assertOk(); + } } From 32b964dcac924d9fa401da215b86995ea5f879a8 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Tue, 8 Sep 2026 08:29:30 +0100 Subject: [PATCH 07/14] refactor(rate-limit): follow exception conventions and fix counter scoping --- docs/2-features/21-rate-limiting.md | 4 ++-- packages/rate-limit/src/GenericRateLimiter.php | 2 +- .../rate-limit/src/Http/ThrottleCounterKey.php | 3 +++ .../rate-limit/src/Http/ThrottleMiddleware.php | 18 ++++++++++++++++-- packages/rate-limit/src/RateLimitException.php | 6 ++---- ...HasNoKey.php => RateLimitKeyWasMissing.php} | 11 +++++++---- .../rate-limit/src/RateLimitWasExceeded.php | 4 +++- .../src/Storage/RateLimitStorageFailed.php | 10 ++++++---- .../src/Storage/RedisRateLimitStorage.php | 2 +- packages/rate-limit/tests/RateLimiterTest.php | 4 ++-- 10 files changed, 43 insertions(+), 21 deletions(-) rename packages/rate-limit/src/{RateLimitHasNoKey.php => RateLimitKeyWasMissing.php} (58%) diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index db37e02456..793411daa5 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -182,7 +182,7 @@ final readonly class SendVerificationEmail ``` -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. +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\RateLimitKeyWasMissing`} 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`}: @@ -208,7 +208,7 @@ $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. +Exceeding limits via `throttle()` throws {b`Tempest\RateLimit\RateLimitWasExceeded`} (implementing {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 diff --git a/packages/rate-limit/src/GenericRateLimiter.php b/packages/rate-limit/src/GenericRateLimiter.php index 80986d4870..2395e13905 100644 --- a/packages/rate-limit/src/GenericRateLimiter.php +++ b/packages/rate-limit/src/GenericRateLimiter.php @@ -48,7 +48,7 @@ public function clear(RateLimit $limit): void */ private function key(RateLimit $limit): string { - return $limit->key ?? throw RateLimitHasNoKey::forLimit($limit); + return $limit->key ?? throw new RateLimitKeyWasMissing($limit); } /** diff --git a/packages/rate-limit/src/Http/ThrottleCounterKey.php b/packages/rate-limit/src/Http/ThrottleCounterKey.php index 08f0709af7..8eebfd205d 100644 --- a/packages/rate-limit/src/Http/ThrottleCounterKey.php +++ b/packages/rate-limit/src/Http/ThrottleCounterKey.php @@ -46,9 +46,12 @@ private static function scope(MatchedRoute $matchedRoute, ThrottleScope $scope): $handler->getDeclaringClass()->getName(), $scope->value, ], + // The method is part of the scope: a handler answering both `GET` and `POST` on the + // same URI exposes two routes, each with an allowance of its own. ThrottleScope::ROUTE => [ $handler->getDeclaringClass()->getName(), $handler->getName(), + $matchedRoute->route->method->value, $matchedRoute->route->uri, $scope->value, ], diff --git a/packages/rate-limit/src/Http/ThrottleMiddleware.php b/packages/rate-limit/src/Http/ThrottleMiddleware.php index da634a73d2..b9d44a993d 100644 --- a/packages/rate-limit/src/Http/ThrottleMiddleware.php +++ b/packages/rate-limit/src/Http/ThrottleMiddleware.php @@ -79,8 +79,9 @@ private function resolveLimits(Request $request): array $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); + // same limit twice throttles a route exactly once. When they disagree, the + // narrowest one wins, so a route may tighten the bucket it shares. + $limits[$key] = $this->narrowest($limit->withKey($key), $limits[$key] ?? null); } } } @@ -95,6 +96,19 @@ private function resolveLimits(Request $request): array return $limits; } + /** + * Returns the more restrictive of two limits sharing a counter. Fewer attempts within the same + * counter is the tighter allowance, and an equal one keeps the limit already collected. + */ + private function narrowest(RateLimit $limit, ?RateLimit $collected): RateLimit + { + if ($collected === null) { + return $limit; + } + + return $limit->attempts < $collected->attempts ? $limit : $collected; + } + /** * 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 diff --git a/packages/rate-limit/src/RateLimitException.php b/packages/rate-limit/src/RateLimitException.php index be859fb445..3d413eaca5 100644 --- a/packages/rate-limit/src/RateLimitException.php +++ b/packages/rate-limit/src/RateLimitException.php @@ -4,9 +4,7 @@ namespace Tempest\RateLimit; -use Exception; - /** - * Base class for exceptions thrown by the rate limit component. + * Marks an exception thrown by the rate limit component. */ -abstract class RateLimitException extends Exception {} +interface RateLimitException {} diff --git a/packages/rate-limit/src/RateLimitHasNoKey.php b/packages/rate-limit/src/RateLimitKeyWasMissing.php similarity index 58% rename from packages/rate-limit/src/RateLimitHasNoKey.php rename to packages/rate-limit/src/RateLimitKeyWasMissing.php index 89457273f6..3058e41200 100644 --- a/packages/rate-limit/src/RateLimitHasNoKey.php +++ b/packages/rate-limit/src/RateLimitKeyWasMissing.php @@ -4,11 +4,14 @@ namespace Tempest\RateLimit; -final class RateLimitHasNoKey extends RateLimitException +use Exception; + +final class RateLimitKeyWasMissing extends Exception implements RateLimitException { - public static function forLimit(RateLimit $limit): self - { - return new self(sprintf( + public function __construct( + public readonly RateLimit $limit, + ) { + parent::__construct(sprintf( 'A rate limit of %d attempts was used without a key. Scope it with `withKey()` or `scopedTo()`, ' . 'otherwise it would share a counter with every other keyless limit.', $limit->attempts, diff --git a/packages/rate-limit/src/RateLimitWasExceeded.php b/packages/rate-limit/src/RateLimitWasExceeded.php index 345996e9bc..f9bb6dce7a 100644 --- a/packages/rate-limit/src/RateLimitWasExceeded.php +++ b/packages/rate-limit/src/RateLimitWasExceeded.php @@ -4,7 +4,9 @@ namespace Tempest\RateLimit; -final class RateLimitWasExceeded extends RateLimitException +use Exception; + +final class RateLimitWasExceeded extends Exception implements RateLimitException { public function __construct( public readonly RateLimitResult $result, diff --git a/packages/rate-limit/src/Storage/RateLimitStorageFailed.php b/packages/rate-limit/src/Storage/RateLimitStorageFailed.php index a2e7b4a3ed..a51d9854e6 100644 --- a/packages/rate-limit/src/Storage/RateLimitStorageFailed.php +++ b/packages/rate-limit/src/Storage/RateLimitStorageFailed.php @@ -4,12 +4,14 @@ namespace Tempest\RateLimit\Storage; +use Exception; use Tempest\RateLimit\RateLimitException; -final class RateLimitStorageFailed extends RateLimitException +final class RateLimitStorageFailed extends Exception implements RateLimitException { - public static function redisDidNotReportAWindow(string $key): self - { - return new self(sprintf('Redis did not report a window for `%s` after recording an attempt against it.', $key)); + public function __construct( + public readonly string $key, + ) { + parent::__construct(sprintf('Redis did not report a window for `%s` after recording an attempt against it.', $key)); } } diff --git a/packages/rate-limit/src/Storage/RedisRateLimitStorage.php b/packages/rate-limit/src/Storage/RedisRateLimitStorage.php index 4110276892..87fe2a5cd5 100644 --- a/packages/rate-limit/src/Storage/RedisRateLimitStorage.php +++ b/packages/rate-limit/src/Storage/RedisRateLimitStorage.php @@ -59,7 +59,7 @@ public function increment(string $key, Duration $window, int $by = 1): RateLimit { $windowInSeconds = RateLimitState::windowInSeconds($window); - return $this->toState($this->eval(self::INCREMENT, $key, (string) $windowInSeconds, (string) $by)) ?? throw RateLimitStorageFailed::redisDidNotReportAWindow($key); + return $this->toState($this->eval(self::INCREMENT, $key, (string) $windowInSeconds, (string) $by)) ?? throw new RateLimitStorageFailed($key); } public function remove(string $key): void diff --git a/packages/rate-limit/tests/RateLimiterTest.php b/packages/rate-limit/tests/RateLimiterTest.php index e4b1b9ed63..42c8f55379 100644 --- a/packages/rate-limit/tests/RateLimiterTest.php +++ b/packages/rate-limit/tests/RateLimiterTest.php @@ -15,7 +15,7 @@ use Tempest\RateLimit\Per; use Tempest\RateLimit\RateLimit; use Tempest\RateLimit\RateLimiter; -use Tempest\RateLimit\RateLimitHasNoKey; +use Tempest\RateLimit\RateLimitKeyWasMissing; use Tempest\RateLimit\RateLimitWasExceeded; use Tempest\RateLimit\Storage\CacheRateLimitStorage; @@ -178,7 +178,7 @@ public function an_allowed_attempt_has_nothing_to_wait_for(): void #[Test] public function a_limit_without_a_key_is_rejected(): void { - $this->expectException(RateLimitHasNoKey::class); + $this->expectException(RateLimitKeyWasMissing::class); $this->limiter->attempt(RateLimit::perMinute(1)); } From bb36d1769e05f221908e66875ad9d61e0e0edbc4 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Wed, 9 Sep 2026 00:38:16 +0100 Subject: [PATCH 08/14] fix(rate-limit): keep route buckets apart from application keys --- docs/2-features/21-rate-limiting.md | 12 ++++-- .../rate-limit/src/Http/RateLimitProfile.php | 5 ++- packages/rate-limit/src/Http/Throttle.php | 11 +++--- .../src/Http/ThrottleCounterKey.php | 20 ++++++---- .../src/Http/ThrottleMiddleware.php | 8 +++- packages/rate-limit/src/Http/ThrottleWith.php | 9 ++++- packages/rate-limit/src/Http/Throttles.php | 6 +++ packages/rate-limit/tests/ThrottleTest.php | 8 ++-- .../Controllers/ThrottledController.php | 18 +++++++++ .../SharedCounterRateLimitProfile.php | 21 +++++++++++ .../RateLimit/ThrottleMiddlewareTest.php | 37 +++++++++++++++---- 11 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 tests/Fixtures/RateLimit/SharedCounterRateLimitProfile.php diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index 793411daa5..951362fd09 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -44,7 +44,7 @@ By default, every route and every client gets an independent counter. To share a ``` -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. +A bucket groups routes, not clients: the routes naming it draw from a single allowance, and that allowance is still counted per client. 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. @@ -111,7 +111,7 @@ return new CacheRateLimitConfig( ``` -Resolvers should return `null` for unidentifiable requests, routing them into a collective shared bucket so anonymous traffic remains strictly throttled. +Resolvers should return `null` for unidentifiable requests, routing them into a single collective counter so anonymous traffic remains strictly throttled. ## Limits that depend on the request @@ -152,7 +152,7 @@ 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. +Unkeyed profile limits scope like `#[Throttle]` attributes, generating individual counters per route and client. A limit carrying a key is counted under that key exactly as written, with no scoping added on top—so give it a key that identifies what it counts, such as `login:{$email}`. Such a counter is shared by every route naming it, and is the one kind of HTTP counter that can be inspected or cleared through {b`Tempest\RateLimit\RateLimiter`}. Returning an empty array leaves requests completely unlimited. ## Throttling anything else @@ -267,4 +267,8 @@ $this->http->fromIp('203.0.113.9')->get('/api/posts')->assertStatus(Status::TOO_ ``` -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`}. +The counters behind `#[Throttle]` are keyed internally and are not addressable from a test, buckets included—a bucket is scoped to the client, so naming one would mean hand-assembling a key shape that carries no compatibility guarantee. To assert against a counter directly, give a limit a key of your own through a [rate limit profile](#limits-that-depend-on-the-request). That key is used as written, so it reaches the counter from anywhere: + +```php +$limiter->clear(RateLimit::perMinute(5)->withKey('login:jon@doe.co')); +``` diff --git a/packages/rate-limit/src/Http/RateLimitProfile.php b/packages/rate-limit/src/Http/RateLimitProfile.php index dfb846b14e..eb48feb367 100644 --- a/packages/rate-limit/src/Http/RateLimitProfile.php +++ b/packages/rate-limit/src/Http/RateLimitProfile.php @@ -18,8 +18,9 @@ interface RateLimitProfile * unlimited. * * Limits without a key are scoped to the route and to the client resolved by - * {@see RateLimitKeyResolver}. Limits with a key are scoped to the client alone. Only give a key - * to a limit whose counter should be shared beyond this route. + * {@see RateLimitKeyResolver}. A limit with a key is counted under that key as written, with no + * scoping of its own, so give it one that identifies what it counts, such as `login:{$email}`. + * That counter can be inspected or cleared through {@see \Tempest\RateLimit\RateLimiter}. * * @return RateLimit[] */ diff --git a/packages/rate-limit/src/Http/Throttle.php b/packages/rate-limit/src/Http/Throttle.php index e10b15fbdb..2860bc99b6 100644 --- a/packages/rate-limit/src/Http/Throttle.php +++ b/packages/rate-limit/src/Http/Throttle.php @@ -44,9 +44,10 @@ public function __construct( public int $every = 1, /** - * Identifies the counter this limit is kept in. A named bucket is scoped to the client alone: - * routes naming the same bucket share an allowance. Without a name, the limit gets its own - * counter, scoped to what it was declared on. + * Groups this limit with the ones naming the same bucket: those routes spend from a single + * allowance, per client. Without a name, the limit gets its own counter, scoped to what it + * was declared on. Either way the counter is scoped to the client, so it cannot be addressed + * through {@see \Tempest\RateLimit\RateLimiter}; use a {@see RateLimitProfile} for that. */ public ?string $bucket = null, ) {} @@ -57,14 +58,14 @@ public function resolveLimits(Request $request, Container $container): array } /** - * Returns the rate limit described by this attribute. + * Returns the rate limit described by this attribute. The bucket is not part of it: it groups + * routes rather than naming a counter, and is applied by {@see ThrottleCounterKey}. */ 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 index 8eebfd205d..aa69958bc9 100644 --- a/packages/rate-limit/src/Http/ThrottleCounterKey.php +++ b/packages/rate-limit/src/Http/ThrottleCounterKey.php @@ -8,21 +8,27 @@ use Tempest\Router\MatchedRoute; /** - * Decides which counter a throttled request is spent from. The shape of these keys is not part of - * the public API. To address a counter directly, give the limit a bucket and consume it through - * {@see \Tempest\RateLimit\RateLimiter}. + * Decides which counter a throttled request is spent from. The shape of the keys derived here is not + * part of the public API. To address a counter directly, give the limit a key of your own through a + * {@see RateLimitProfile} and consume it through {@see \Tempest\RateLimit\RateLimiter}. */ final readonly class ThrottleCounterKey { - public static function for(RateLimit $limit, MatchedRoute $matchedRoute, ThrottleScope $scope, ?string $client): string + public static function for(RateLimit $limit, ?string $bucket, MatchedRoute $matchedRoute, ThrottleScope $scope, ?string $client): string { + // A key the application built is the counter itself, and stays addressable through + // `RateLimiter`. Scoping it would name a counter no caller could reach. + if ($limit->key !== null) { + return $limit->key; + } + // Identified clients are prefixed. This way, a resolver returning the string // `unidentified` still gets its own counter rather than the shared one. $client = $client === null ? 'unidentified' : 'client:' . $client; - // A named bucket is scoped to the client alone: routes naming it spend from one allowance. - if ($limit->key !== null) { - return implode(':', ['bucket', $limit->key, $client]); + // A bucket groups routes rather than naming a counter, so it stays scoped to the client. + if ($bucket !== null) { + return implode(':', ['bucket', $bucket, $client]); } return implode(':', [ diff --git a/packages/rate-limit/src/Http/ThrottleMiddleware.php b/packages/rate-limit/src/Http/ThrottleMiddleware.php index b9d44a993d..31ab3b61e1 100644 --- a/packages/rate-limit/src/Http/ThrottleMiddleware.php +++ b/packages/rate-limit/src/Http/ThrottleMiddleware.php @@ -76,7 +76,13 @@ private function resolveLimits(Request $request): array 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); + $key = ThrottleCounterKey::for( + limit: $limit, + bucket: $throttle->bucket, + matchedRoute: $this->matchedRoute, + scope: ThrottleScope::from($scope), + client: $client, + ); // Limits landing in the same counter describe one allowance: declaring the // same limit twice throttles a route exactly once. When they disagree, the diff --git a/packages/rate-limit/src/Http/ThrottleWith.php b/packages/rate-limit/src/Http/ThrottleWith.php index 7fc8614b72..4b029cde40 100644 --- a/packages/rate-limit/src/Http/ThrottleWith.php +++ b/packages/rate-limit/src/Http/ThrottleWith.php @@ -24,6 +24,11 @@ { use AddsThrottleMiddleware; + /** + * A profile names its own counters, so it groups nothing here. + */ + public ?string $bucket; + public function __construct( /** * The profile resolving the limits that apply to a request. @@ -31,7 +36,9 @@ public function __construct( * @var class-string */ public string $profile, - ) {} + ) { + $this->bucket = null; + } public function resolveLimits(Request $request, Container $container): array { diff --git a/packages/rate-limit/src/Http/Throttles.php b/packages/rate-limit/src/Http/Throttles.php index 44e30afaaf..dfd4eac2eb 100644 --- a/packages/rate-limit/src/Http/Throttles.php +++ b/packages/rate-limit/src/Http/Throttles.php @@ -15,6 +15,12 @@ */ interface Throttles extends RouteDecorator { + /** + * Groups the limits of every attribute naming it into one allowance, per client. `null` keeps + * them scoped to what they were declared on. + */ + public ?string $bucket { get; } + /** * Returns the limits this attribute subjects the specified request to. The middleware scopes them * through {@see ThrottleCounterKey} before consuming any. diff --git a/packages/rate-limit/tests/ThrottleTest.php b/packages/rate-limit/tests/ThrottleTest.php index 6b138e69b8..c30dbabce8 100644 --- a/packages/rate-limit/tests/ThrottleTest.php +++ b/packages/rate-limit/tests/ThrottleTest.php @@ -32,11 +32,13 @@ public function the_window_defaults_to_a_single_minute(): void } #[Test] - public function a_named_bucket_becomes_the_limits_key(): void + public function a_named_bucket_stays_off_the_limit(): void { - $limit = new Throttle(attempts: 10, bucket: 'api')->toRateLimit(); + $throttle = new Throttle(attempts: 10, bucket: 'api'); - $this->assertSame('api', $limit->key); + // Keeping the bucket off the limit is what tells it apart from an application's own key. + $this->assertSame('api', $throttle->bucket); + $this->assertNull($throttle->toRateLimit()->key); } #[Test] diff --git a/tests/Fixtures/Controllers/ThrottledController.php b/tests/Fixtures/Controllers/ThrottledController.php index 6842323a65..b42f01e2a5 100644 --- a/tests/Fixtures/Controllers/ThrottledController.php +++ b/tests/Fixtures/Controllers/ThrottledController.php @@ -12,6 +12,7 @@ use Tempest\Router\Get; use Tempest\Router\Post; use Tests\Tempest\Fixtures\RateLimit\PremiumRateLimitProfile; +use Tests\Tempest\Fixtures\RateLimit\SharedCounterRateLimitProfile; use Tests\Tempest\Fixtures\RateLimit\TieredRateLimitProfile; final readonly class ThrottledController @@ -90,6 +91,23 @@ public function sharedBucketSecond(): Response return new Ok('allowed'); } + #[ThrottleWith(SharedCounterRateLimitProfile::class)] + #[Get('/throttled-by-shared-counter/first')] + public function sharedCounterFirst(): Response + { + return new Ok('allowed'); + } + + /** + * Uses the same profile as `sharedCounterFirst`, so both routes spend from the counter it names. + */ + #[ThrottleWith(SharedCounterRateLimitProfile::class)] + #[Get('/throttled-by-shared-counter/second')] + public function sharedCounterSecond(): Response + { + return new Ok('allowed'); + } + /** * Declares the wider window first. Consuming limits in declaration order would burn the daily * allowance on requests the per-minute limit already rejected. diff --git a/tests/Fixtures/RateLimit/SharedCounterRateLimitProfile.php b/tests/Fixtures/RateLimit/SharedCounterRateLimitProfile.php new file mode 100644 index 0000000000..3fe55eead7 --- /dev/null +++ b/tests/Fixtures/RateLimit/SharedCounterRateLimitProfile.php @@ -0,0 +1,21 @@ +withKey('shared-counter')]; + } +} diff --git a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php index d643ec850f..4975c5233d 100644 --- a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php +++ b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php @@ -324,26 +324,47 @@ public function http_methods_on_the_same_handler_have_independent_allowances(): } #[Test] - public function a_named_http_bucket_can_be_inspected_through_the_limiter(): void + public function a_counter_named_by_a_profile_can_be_inspected_through_the_limiter(): 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-counter/first')->assertOk(); - // The rate-limiting documentation promises direct access by naming the HTTP bucket. - $result = $this->container->get(RateLimiter::class)->peek(RateLimit::perMinute(2)->withKey('shared')); + // A key the application builds is used as written, so it addresses the counter directly. + $result = $this->container->get(RateLimiter::class)->peek(RateLimit::perMinute(2)->withKey('shared-counter')); $this->assertSame(1, $result->hits); $this->assertSame(1, $result->remaining); } #[Test] - public function a_named_http_bucket_can_be_cleared_through_the_limiter(): void + public function a_counter_named_by_a_profile_can_be_cleared_through_the_limiter(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-counter/first')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-counter/second')->assertOk(); + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-counter/first')->assertStatus(Status::TOO_MANY_REQUESTS); + + $this->container->get(RateLimiter::class)->clear(RateLimit::perMinute(2)->withKey('shared-counter')); + + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-counter/first')->assertOk(); + } + + #[Test] + public function a_counter_named_by_a_profile_is_not_scoped_to_the_client(): void + { + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-counter/first')->assertOk(); + $this->http->fromIp('203.0.113.10')->get('/throttled-by-shared-counter/first')->assertOk(); + + // The profile's key carries no client, so it counts every client into one allowance. + $this->http->fromIp('203.0.113.11')->get('/throttled-by-shared-counter/first')->assertStatus(Status::TOO_MANY_REQUESTS); + } + + #[Test] + public function a_named_bucket_is_scoped_to_the_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('203.0.113.9')->get('/throttled-by-shared-bucket/first')->assertStatus(Status::TOO_MANY_REQUESTS); - $this->container->get(RateLimiter::class)->clear(RateLimit::perMinute(2)->withKey('shared')); - - $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/first')->assertOk(); + // A bucket is a constant, so it groups routes per client rather than across all of them. + $this->http->fromIp('203.0.113.10')->get('/throttled-by-shared-bucket/first')->assertOk(); } } From 4e0df5aafe1c0d43b3f2bb185a7adea599ab9a90 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Wed, 9 Sep 2026 20:03:10 +0100 Subject: [PATCH 09/14] fix(rate-limit): keep bucket counters apart by window --- docs/2-features/21-rate-limiting.md | 4 ++-- .../rate-limit/src/Http/ThrottleCounterKey.php | 18 +++++++++++++++--- .../Controllers/ThrottledController.php | 11 +++++++++++ .../RateLimit/ThrottleMiddlewareTest.php | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index 951362fd09..626eb94012 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -44,9 +44,9 @@ By default, every route and every client gets an independent counter. To share a ``` -A bucket groups routes, not clients: the routes naming it draw from a single allowance, and that allowance is still counted per client. Unnamed limits are automatically scoped by their exact allowance criteria, meaning attributes can be reordered freely without breaking counters. +A bucket groups routes, not clients: the routes naming it draw from a single allowance, and that allowance is still counted per client. Routes may name the same bucket with different attempt counts—the narrowest of them decides how much allowance there is, so a single route can tighten the bucket it shares. They are grouped per window, though: limits measuring different spans keep counters of their own, since one counter can only last one span. 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. +Changing an allowance resets its counter, lifting current limits. A named bucket keeps its counter when the attempts change, but not when the window does. 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. diff --git a/packages/rate-limit/src/Http/ThrottleCounterKey.php b/packages/rate-limit/src/Http/ThrottleCounterKey.php index aa69958bc9..cd3c1a5a3f 100644 --- a/packages/rate-limit/src/Http/ThrottleCounterKey.php +++ b/packages/rate-limit/src/Http/ThrottleCounterKey.php @@ -26,9 +26,13 @@ public static function for(RateLimit $limit, ?string $bucket, MatchedRoute $matc // `unidentified` still gets its own counter rather than the shared one. $client = $client === null ? 'unidentified' : 'client:' . $client; - // A bucket groups routes rather than naming a counter, so it stays scoped to the client. + // A bucket groups routes rather than naming a counter, so it stays scoped to the client. The + // window is part of the key, but the attempts are not: routes sharing a bucket spend from one + // allowance, and the narrowest of them decides how much of it there is. Were the window left + // out, limits measuring different spans would land in one counter, and whichever request + // opened it would decide how long it lasts. if ($bucket !== null) { - return implode(':', ['bucket', $bucket, $client]); + return implode(':', ['bucket', $bucket, self::window($limit), $client]); } return implode(':', [ @@ -71,6 +75,14 @@ private static function scope(MatchedRoute $matchedRoute, ThrottleScope $scope): */ private static function allowance(RateLimit $limit): string { - return "{$limit->attempts}_{$limit->window->getTotalSeconds()}"; + return "{$limit->attempts}_" . self::window($limit); + } + + /** + * Returns the span the limit measures, in seconds. + */ + private static function window(RateLimit $limit): string + { + return (string) $limit->window->getTotalSeconds(); } } diff --git a/tests/Fixtures/Controllers/ThrottledController.php b/tests/Fixtures/Controllers/ThrottledController.php index b42f01e2a5..d59933226c 100644 --- a/tests/Fixtures/Controllers/ThrottledController.php +++ b/tests/Fixtures/Controllers/ThrottledController.php @@ -91,6 +91,17 @@ public function sharedBucketSecond(): Response return new Ok('allowed'); } + /** + * Names the same bucket as `sharedBucketFirst`, but measures a different span. A bucket groups + * routes within one window, so this route spends an allowance of its own. + */ + #[Throttle(attempts: 2, per: Per::HOUR, bucket: 'shared')] + #[Get('/throttled-by-shared-bucket/hourly')] + public function sharedBucketHourly(): Response + { + return new Ok('allowed'); + } + #[ThrottleWith(SharedCounterRateLimitProfile::class)] #[Get('/throttled-by-shared-counter/first')] public function sharedCounterFirst(): Response diff --git a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php index 4975c5233d..bef6f178eb 100644 --- a/tests/Integration/RateLimit/ThrottleMiddlewareTest.php +++ b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php @@ -286,6 +286,22 @@ public function routes_naming_the_same_bucket_share_an_allowance(): void ->assertStatus(Status::TOO_MANY_REQUESTS); } + #[Test] + public function a_shared_bucket_keeps_windows_of_different_spans_apart(): void + { + // Exhausts the per-minute allowance of the `shared` bucket. + $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('203.0.113.9') + ->get('/throttled-by-shared-bucket/first') + ->assertStatus(Status::TOO_MANY_REQUESTS); + + // The hourly limit names the same bucket, but measures another span. Sharing a counter with + // the limits above would leave the span of the window to whichever request opened it. + $this->http->fromIp('203.0.113.9')->get('/throttled-by-shared-bucket/hourly')->assertOk(); + } + #[Test] public function a_shared_bucket_is_still_scoped_per_client(): void { From e5d933f470ea1ed591692c4d63d5028dcce3b952 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Wed, 9 Sep 2026 20:14:22 +0100 Subject: [PATCH 10/14] fix(rate-limit): turn an unreachable counter into a rejection instead of an error --- docs/2-features/21-rate-limiting.md | 2 ++ .../src/Config/CacheRateLimitConfig.php | 11 ++++++- .../src/Http/ThrottleMiddleware.php | 10 +++++- .../src/Storage/CacheRateLimitStorage.php | 31 ++++++++++++------- .../src/Storage/RateLimitStorageFailed.php | 9 +++++- .../RateLimit/UnreachableRateLimitStorage.php | 31 +++++++++++++++++++ .../RateLimit/ThrottleMiddlewareTest.php | 17 ++++++++++ 7 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 tests/Fixtures/RateLimit/UnreachableRateLimitStorage.php diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index 626eb94012..c1c37f7da9 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -214,6 +214,8 @@ Exceeding limits via `throttle()` throws {b`Tempest\RateLimit\RateLimitWasExceed 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. +Requests for one counter wait for each other, for up to `lockWaitInMilliseconds`. A counter still locked by then cannot be read, so the attempt has no outcome and the request is turned away with a `429`—letting it through would leave the route unmetered exactly when it is under load. Counters are scoped per client, so a client contending with itself is the one held back. + 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 diff --git a/packages/rate-limit/src/Config/CacheRateLimitConfig.php b/packages/rate-limit/src/Config/CacheRateLimitConfig.php index 9dfb48d09e..9794d2bffb 100644 --- a/packages/rate-limit/src/Config/CacheRateLimitConfig.php +++ b/packages/rate-limit/src/Config/CacheRateLimitConfig.php @@ -24,10 +24,19 @@ public function __construct( public string $keyPrefix = 'rate_limit', /** - * Lock timeout for concurrent updates. + * How long a lock on a counter is held before it is considered abandoned. This only has to + * outlast a single update. */ public int $lockTimeoutInSeconds = 5, + /** + * How long to wait for a counter locked by another process. Requests for one counter are + * serialized, so this is the delay a client may add to its own requests before being turned + * away. Waiting longer holds a worker for longer, which is the opposite of what a limit is + * for. + */ + public int $lockWaitInMilliseconds = 250, + /** * Whether HTTP responses include `X-RateLimit-*` headers. These headers are per-client and must * not be cached by a shared proxy. diff --git a/packages/rate-limit/src/Http/ThrottleMiddleware.php b/packages/rate-limit/src/Http/ThrottleMiddleware.php index 31ab3b61e1..8ff8b34d88 100644 --- a/packages/rate-limit/src/Http/ThrottleMiddleware.php +++ b/packages/rate-limit/src/Http/ThrottleMiddleware.php @@ -14,6 +14,7 @@ use Tempest\RateLimit\RateLimit; use Tempest\RateLimit\RateLimiter; use Tempest\RateLimit\RateLimitResult; +use Tempest\RateLimit\Storage\RateLimitStorageFailed; use Tempest\Router\HttpMiddleware; use Tempest\Router\HttpMiddlewareCallable; use Tempest\Router\MatchedRoute; @@ -46,7 +47,14 @@ public function __invoke(Request $request, HttpMiddlewareCallable $next): Respon // 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); + try { + $result = $this->limiter->attempt($limit); + } catch (RateLimitStorageFailed $failure) { + throw new HttpRequestFailed( + status: Status::SERVICE_UNAVAILABLE, + message: $failure->getMessage(), + ); + } if ($result->exceeded) { $this->reject($result); diff --git a/packages/rate-limit/src/Storage/CacheRateLimitStorage.php b/packages/rate-limit/src/Storage/CacheRateLimitStorage.php index 8cff6adf2d..628fc00029 100644 --- a/packages/rate-limit/src/Storage/CacheRateLimitStorage.php +++ b/packages/rate-limit/src/Storage/CacheRateLimitStorage.php @@ -5,6 +5,7 @@ namespace Tempest\RateLimit\Storage; use Tempest\Cache\Cache; +use Tempest\Cache\LockAcquisitionTimedOut; use Tempest\Clock\Clock; use Tempest\DateTime\Duration; use Tempest\RateLimit\Config\CacheRateLimitConfig; @@ -44,20 +45,26 @@ public function increment(string $key, Duration $window, int $by = 1): RateLimit 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); + try { + 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())), - ); + $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), - ); + return $state; + }, + wait: Duration::milliseconds($this->config->lockWaitInMilliseconds), + ); + } catch (LockAcquisitionTimedOut $timeout) { + // The counter could not be read, so the attempt has no outcome. Reporting one either way + // would be a guess: a window that is not open yet looks the same as an exhausted one. + throw new RateLimitStorageFailed($key, 'the counter was locked by another process', previous: $timeout); + } } public function remove(string $key): void diff --git a/packages/rate-limit/src/Storage/RateLimitStorageFailed.php b/packages/rate-limit/src/Storage/RateLimitStorageFailed.php index a51d9854e6..1681b20935 100644 --- a/packages/rate-limit/src/Storage/RateLimitStorageFailed.php +++ b/packages/rate-limit/src/Storage/RateLimitStorageFailed.php @@ -6,12 +6,19 @@ use Exception; use Tempest\RateLimit\RateLimitException; +use Throwable; +/** + * Thrown when the window for a key could not be read or recorded. The attempt it belongs to has no + * outcome: the counter behind it is unknown, not within its limit. + */ final class RateLimitStorageFailed extends Exception implements RateLimitException { public function __construct( public readonly string $key, + string $reason = 'the storage did not report a window after recording an attempt against it', + ?Throwable $previous = null, ) { - parent::__construct(sprintf('Redis did not report a window for `%s` after recording an attempt against it.', $key)); + parent::__construct(sprintf('The rate limit for `%s` could not be recorded: %s.', $key, $reason), previous: $previous); } } diff --git a/tests/Fixtures/RateLimit/UnreachableRateLimitStorage.php b/tests/Fixtures/RateLimit/UnreachableRateLimitStorage.php new file mode 100644 index 0000000000..44de13fe3f --- /dev/null +++ b/tests/Fixtures/RateLimit/UnreachableRateLimitStorage.php @@ -0,0 +1,31 @@ +assertStatus(Status::TOO_MANY_REQUESTS); } + #[Test] + public function a_rate_limit_storage_failure_returns_service_unavailable(): void + { + $this->container->singleton(RateLimiter::class, new GenericRateLimiter( + storage: new UnreachableRateLimitStorage(), + clock: $this->container->get(Clock::class), + )); + + $this->http + ->fromIp('203.0.113.9') + ->get('/throttled') + ->assertStatus(Status::SERVICE_UNAVAILABLE); + } + #[Test] public function a_shared_bucket_keeps_windows_of_different_spans_apart(): void { From f0fb6055f1db28c55badd0d7c891c34d1a25e7e2 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 12 Sep 2026 03:43:58 +0100 Subject: [PATCH 11/14] refactor(rate-limit): unify throttle attributes --- docs/2-features/21-rate-limiting.md | 6 +- .../src/Http/AddsThrottleMiddleware.php | 28 -------- .../rate-limit/src/Http/RateLimitProfile.php | 4 +- packages/rate-limit/src/Http/Throttle.php | 65 ++++++++++++++++--- .../src/Http/ThrottleMiddleware.php | 8 +-- packages/rate-limit/src/Http/ThrottleWith.php | 47 -------------- packages/rate-limit/src/Http/Throttles.php | 31 --------- packages/rate-limit/tests/ThrottleTest.php | 27 ++++++++ .../Controllers/ThrottledController.php | 9 ++- 9 files changed, 96 insertions(+), 129 deletions(-) delete mode 100644 packages/rate-limit/src/Http/AddsThrottleMiddleware.php delete mode 100644 packages/rate-limit/src/Http/ThrottleWith.php delete mode 100644 packages/rate-limit/src/Http/Throttles.php diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index c1c37f7da9..7f8fd2d6d8 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -140,12 +140,12 @@ final readonly class ApiRateLimitProfile implements RateLimitProfile ``` -Reference the profile using the {b`Tempest\RateLimit\Http\ThrottleWith`} attribute: +Reference the profile using the `profile` argument on the {b`Tempest\RateLimit\Http\Throttle`} attribute: ```php -use Tempest\RateLimit\Http\ThrottleWith; +use Tempest\RateLimit\Http\Throttle; -#[ThrottleWith(ApiRateLimitProfile::class)] +#[Throttle(profile: ApiRateLimitProfile::class)] #[Get('/api/posts')] public function index(): Response { /* … */ } diff --git a/packages/rate-limit/src/Http/AddsThrottleMiddleware.php b/packages/rate-limit/src/Http/AddsThrottleMiddleware.php deleted file mode 100644 index 9c02b5362b..0000000000 --- a/packages/rate-limit/src/Http/AddsThrottleMiddleware.php +++ /dev/null @@ -1,28 +0,0 @@ -middleware, strict: true)) { - return $route; - } - - $route->middleware = [ - ...$route->middleware, - ThrottleMiddleware::class, - ]; - - return $route; - } -} diff --git a/packages/rate-limit/src/Http/RateLimitProfile.php b/packages/rate-limit/src/Http/RateLimitProfile.php index eb48feb367..e2c219bfe5 100644 --- a/packages/rate-limit/src/Http/RateLimitProfile.php +++ b/packages/rate-limit/src/Http/RateLimitProfile.php @@ -8,8 +8,8 @@ use Tempest\RateLimit\RateLimit; /** - * Describes the rate limits that apply to a request. Referenced from {@see ThrottleWith}, a profile - * is used instead of {@see Throttle} when the limits depend on the request itself. + * Describes the rate limits that apply to a request. Referenced from {@see Throttle}, a profile + * is used when the limits depend on the request itself. */ interface RateLimitProfile { diff --git a/packages/rate-limit/src/Http/Throttle.php b/packages/rate-limit/src/Http/Throttle.php index 2860bc99b6..a7c425a608 100644 --- a/packages/rate-limit/src/Http/Throttle.php +++ b/packages/rate-limit/src/Http/Throttle.php @@ -9,6 +9,9 @@ use Tempest\Http\Request; use Tempest\RateLimit\Per; use Tempest\RateLimit\RateLimit; +use Tempest\Router\Route; +use Tempest\Router\RouteDecorator; +use InvalidArgumentException; /** * Limits how often a route may be requested. The attribute is repeatable: a route may be subject to several limits at once. @@ -20,28 +23,27 @@ * public function index(): Response { /* … *\/ } * ``` * - * When the limits depend on the request itself, use {@see ThrottleWith} instead. + * When the limits depend on the request itself, provide a {@see RateLimitProfile} instead of + * `attempts`. */ #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] -final readonly class Throttle implements Throttles +final readonly class Throttle implements RouteDecorator { - use AddsThrottleMiddleware; - public function __construct( /** * The maximum amount of requests allowed within the window. */ - public int $attempts, + public readonly ?int $attempts = null, /** * The unit of time the window is expressed in. */ - public Per $per = Per::MINUTE, + public readonly Per $per = Per::MINUTE, /** * How many `$per` units the window spans. For instance, `per: Per::MINUTE, every: 5` is five minutes. */ - public int $every = 1, + public readonly int $every = 1, /** * Groups this limit with the ones naming the same bucket: those routes spend from a single @@ -49,20 +51,65 @@ public function __construct( * was declared on. Either way the counter is scoped to the client, so it cannot be addressed * through {@see \Tempest\RateLimit\RateLimiter}; use a {@see RateLimitProfile} for that. */ - public ?string $bucket = null, - ) {} + public readonly ?string $bucket = null, + + /** + * Resolves limits dynamically from the request. This cannot be combined with a static limit. + * + * @var class-string|null + */ + public readonly ?string $profile = null, + ) { + if ($profile !== null && ($attempts !== null || $per !== Per::MINUTE || $every !== 1 || $bucket !== null)) { + throw new InvalidArgumentException('A rate limit profile cannot be combined with attempts, per, every, or bucket.'); + } + + if ($profile === null && $attempts === null) { + throw new InvalidArgumentException('A rate limit must provide either attempts or a profile.'); + } + + if ($attempts !== null && $attempts < 1) { + throw new InvalidArgumentException('Rate limit attempts must be greater than zero.'); + } + + if ($every < 1) { + throw new InvalidArgumentException('Rate limit every must be greater than zero.'); + } + } public function resolveLimits(Request $request, Container $container): array { + if ($this->profile !== null) { + return $container->get($this->profile)->resolve($request); + } + return [$this->toRateLimit()]; } + public function decorate(Route $route): Route + { + if (in_array(ThrottleMiddleware::class, $route->middleware, strict: true)) { + return $route; + } + + $route->middleware = [ + ...$route->middleware, + ThrottleMiddleware::class, + ]; + + return $route; + } + /** * Returns the rate limit described by this attribute. The bucket is not part of it: it groups * routes rather than naming a counter, and is applied by {@see ThrottleCounterKey}. */ public function toRateLimit(): RateLimit { + if ($this->attempts === null) { + throw new InvalidArgumentException('A rate limit profile cannot be converted to a static rate limit.'); + } + return new RateLimit( attempts: $this->attempts, window: $this->per->toDuration($this->every), diff --git a/packages/rate-limit/src/Http/ThrottleMiddleware.php b/packages/rate-limit/src/Http/ThrottleMiddleware.php index 8ff8b34d88..a7bf6586ed 100644 --- a/packages/rate-limit/src/Http/ThrottleMiddleware.php +++ b/packages/rate-limit/src/Http/ThrottleMiddleware.php @@ -20,7 +20,7 @@ use Tempest\Router\MatchedRoute; /** - * Applies the limits declared by {@see Throttle} and {@see ThrottleWith} to the matched route. This + * Applies the limits declared by {@see Throttle} to the matched route. This * middleware is not discovered globally. It is added to a route by the attributes themselves. */ #[SkipDiscovery] @@ -128,15 +128,15 @@ private function narrowest(RateLimit $limit, ?RateLimit $collected): RateLimit * 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 + * @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), + ThrottleScope::ROUTE->value => $handler->getAttributes(Throttle::class), + ThrottleScope::CONTROLLER->value => $handler->getDeclaringClass()->getAttributes(Throttle::class), ]); } diff --git a/packages/rate-limit/src/Http/ThrottleWith.php b/packages/rate-limit/src/Http/ThrottleWith.php deleted file mode 100644 index 4b029cde40..0000000000 --- a/packages/rate-limit/src/Http/ThrottleWith.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ - public string $profile, - ) { - $this->bucket = null; - } - - 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 deleted file mode 100644 index dfd4eac2eb..0000000000 --- a/packages/rate-limit/src/Http/Throttles.php +++ /dev/null @@ -1,31 +0,0 @@ -assertNull($limit->key); } + + #[Test] + public function a_profile_can_replace_a_static_limit(): void + { + $throttle = new Throttle(profile: RateLimitProfile::class); + + $this->assertSame(RateLimitProfile::class, $throttle->profile); + $this->assertNull($throttle->attempts); + } + + #[Test] + public function a_profile_cannot_be_combined_with_a_static_limit(): void + { + $this->expectException(InvalidArgumentException::class); + + new Throttle(attempts: 10, profile: RateLimitProfile::class); + } + + #[Test] + public function a_throttle_must_define_a_limit_or_profile(): void + { + $this->expectException(InvalidArgumentException::class); + + new Throttle(); + } } diff --git a/tests/Fixtures/Controllers/ThrottledController.php b/tests/Fixtures/Controllers/ThrottledController.php index d59933226c..9bd251efd7 100644 --- a/tests/Fixtures/Controllers/ThrottledController.php +++ b/tests/Fixtures/Controllers/ThrottledController.php @@ -7,7 +7,6 @@ use Tempest\Http\Response; use Tempest\Http\Responses\Ok; use Tempest\RateLimit\Http\Throttle; -use Tempest\RateLimit\Http\ThrottleWith; use Tempest\RateLimit\Per; use Tempest\Router\Get; use Tempest\Router\Post; @@ -40,14 +39,14 @@ public function twice(): Response return new Ok('allowed'); } - #[ThrottleWith(PremiumRateLimitProfile::class)] + #[Throttle(profile: PremiumRateLimitProfile::class)] #[Get('/throttled-by-profile')] public function profile(): Response { return new Ok('allowed'); } - #[ThrottleWith(TieredRateLimitProfile::class)] + #[Throttle(profile: TieredRateLimitProfile::class)] #[Get('/throttled-by-tiers')] public function tiers(): Response { @@ -102,7 +101,7 @@ public function sharedBucketHourly(): Response return new Ok('allowed'); } - #[ThrottleWith(SharedCounterRateLimitProfile::class)] + #[Throttle(profile: SharedCounterRateLimitProfile::class)] #[Get('/throttled-by-shared-counter/first')] public function sharedCounterFirst(): Response { @@ -112,7 +111,7 @@ public function sharedCounterFirst(): Response /** * Uses the same profile as `sharedCounterFirst`, so both routes spend from the counter it names. */ - #[ThrottleWith(SharedCounterRateLimitProfile::class)] + #[Throttle(profile: SharedCounterRateLimitProfile::class)] #[Get('/throttled-by-shared-counter/second')] public function sharedCounterSecond(): Response { From 06bd39907a1ac007fa2ae212b352c91a2ed8277c Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 12 Sep 2026 03:50:32 +0100 Subject: [PATCH 12/14] chore: mago formatting --- packages/rate-limit/src/Http/Throttle.php | 2 +- packages/rate-limit/tests/ThrottleTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rate-limit/src/Http/Throttle.php b/packages/rate-limit/src/Http/Throttle.php index a7c425a608..85a12ed7db 100644 --- a/packages/rate-limit/src/Http/Throttle.php +++ b/packages/rate-limit/src/Http/Throttle.php @@ -5,13 +5,13 @@ namespace Tempest\RateLimit\Http; use Attribute; +use InvalidArgumentException; use Tempest\Container\Container; use Tempest\Http\Request; use Tempest\RateLimit\Per; use Tempest\RateLimit\RateLimit; use Tempest\Router\Route; use Tempest\Router\RouteDecorator; -use InvalidArgumentException; /** * Limits how often a route may be requested. The attribute is repeatable: a route may be subject to several limits at once. diff --git a/packages/rate-limit/tests/ThrottleTest.php b/packages/rate-limit/tests/ThrottleTest.php index 78dab3a0a9..63297ee646 100644 --- a/packages/rate-limit/tests/ThrottleTest.php +++ b/packages/rate-limit/tests/ThrottleTest.php @@ -7,9 +7,9 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Tempest\RateLimit\Http\RateLimitProfile; use Tempest\RateLimit\Http\Throttle; use Tempest\RateLimit\Per; -use Tempest\RateLimit\Http\RateLimitProfile; /** * @internal From f322aa0ee205f633d53c7f4fabd502b5b9f256b7 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 12 Sep 2026 03:54:12 +0100 Subject: [PATCH 13/14] chore: mago formatting --- packages/rate-limit/src/Http/Throttle.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/rate-limit/src/Http/Throttle.php b/packages/rate-limit/src/Http/Throttle.php index 85a12ed7db..83e8ff6970 100644 --- a/packages/rate-limit/src/Http/Throttle.php +++ b/packages/rate-limit/src/Http/Throttle.php @@ -33,17 +33,17 @@ public function __construct( /** * The maximum amount of requests allowed within the window. */ - public readonly ?int $attempts = null, + public ?int $attempts = null, /** * The unit of time the window is expressed in. */ - public readonly Per $per = Per::MINUTE, + public Per $per = Per::MINUTE, /** * How many `$per` units the window spans. For instance, `per: Per::MINUTE, every: 5` is five minutes. */ - public readonly int $every = 1, + public int $every = 1, /** * Groups this limit with the ones naming the same bucket: those routes spend from a single @@ -51,14 +51,14 @@ public function __construct( * was declared on. Either way the counter is scoped to the client, so it cannot be addressed * through {@see \Tempest\RateLimit\RateLimiter}; use a {@see RateLimitProfile} for that. */ - public readonly ?string $bucket = null, + public ?string $bucket = null, /** * Resolves limits dynamically from the request. This cannot be combined with a static limit. * * @var class-string|null */ - public readonly ?string $profile = null, + public ?string $profile = null, ) { if ($profile !== null && ($attempts !== null || $per !== Per::MINUTE || $every !== 1 || $bucket !== null)) { throw new InvalidArgumentException('A rate limit profile cannot be combined with attempts, per, every, or bucket.'); From c523e2c1ccbcbcee6875f3288322fe3ab3444c39 Mon Sep 17 00:00:00 2001 From: Ostap Brehin Date: Sat, 12 Sep 2026 04:01:02 +0100 Subject: [PATCH 14/14] docs --- docs/2-features/21-rate-limiting.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/2-features/21-rate-limiting.md b/docs/2-features/21-rate-limiting.md index 7f8fd2d6d8..298fbae715 100644 --- a/docs/2-features/21-rate-limiting.md +++ b/docs/2-features/21-rate-limiting.md @@ -44,7 +44,7 @@ By default, every route and every client gets an independent counter. To share a ``` -A bucket groups routes, not clients: the routes naming it draw from a single allowance, and that allowance is still counted per client. Routes may name the same bucket with different attempt counts—the narrowest of them decides how much allowance there is, so a single route can tighten the bucket it shares. They are grouped per window, though: limits measuring different spans keep counters of their own, since one counter can only last one span. Unnamed limits are automatically scoped by their exact allowance criteria, meaning attributes can be reordered freely without breaking counters. +A bucket shares one allowance across the routes that name it, while keeping counters separate for each client. If those routes use different attempt counts with the same window, the smallest limit applies. Different window lengths use separate counters. Without a bucket, each limit gets its own counter based on its route and allowance. Changing an allowance resets its counter, lifting current limits. A named bucket keeps its counter when the attempts change, but not when the window does. @@ -76,7 +76,7 @@ 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. +Limits run from the shortest window to the longest. Rejected requests do not consume later quotas. ## Choosing what to count @@ -152,7 +152,7 @@ public function index(): Response ``` -Unkeyed profile limits scope like `#[Throttle]` attributes, generating individual counters per route and client. A limit carrying a key is counted under that key exactly as written, with no scoping added on top—so give it a key that identifies what it counts, such as `login:{$email}`. Such a counter is shared by every route naming it, and is the one kind of HTTP counter that can be inspected or cleared through {b`Tempest\RateLimit\RateLimiter`}. Returning an empty array leaves requests completely unlimited. +Unkeyed profile limits use the same route or controller scope as `#[Throttle]`, with a separate counter for each client. Keyed limits use the key exactly as provided; no route or client scope is added. Choose a key that identifies the resource being limited, such as `login:{$email}`. Such a counter is shared by every route naming it, and is the one kind of HTTP counter that can be inspected or cleared through {b`Tempest\RateLimit\RateLimiter`}. Returning an empty array leaves requests completely unlimited. ## Throttling anything else @@ -214,7 +214,7 @@ Exceeding limits via `throttle()` throws {b`Tempest\RateLimit\RateLimitWasExceed 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. -Requests for one counter wait for each other, for up to `lockWaitInMilliseconds`. A counter still locked by then cannot be read, so the attempt has no outcome and the request is turned away with a `429`—letting it through would leave the route unmetered exactly when it is under load. Counters are scoped per client, so a client contending with itself is the one held back. +Requests for one counter wait for each other, for up to `lockWaitInMilliseconds`. If the lock cannot be acquired in time, the counter cannot be read safely and the request fails with `503 Service Unavailable`. This avoids guessing whether the request should be allowed or rejected. For high-concurrency production environments, switch to {b`Tempest\RateLimit\Config\RedisRateLimitConfig`}, which stores windows in Redis using atomic Lua-script increments: