diff --git a/composer.json b/composer.json index 8b52e1f50..400d1611f 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 000000000..db37e0245 --- /dev/null +++ b/docs/2-features/21-rate-limiting.md @@ -0,0 +1,270 @@ +--- +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 your rate limit configuration. + +### 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\CacheRateLimitConfig; + +return new CacheRateLimitConfig( + 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`}, 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\Config\RedisRateLimitConfig`}, which stores windows in Redis using atomic Lua-script increments: + +```php app/rateLimit.config.php +use Tempest\RateLimit\Config\RedisRateLimitConfig; + +return new RedisRateLimitConfig(); + +``` + +Custom storage engines can be integrated by implementing {b`Tempest\RateLimit\Config\RateLimitConfig`} and returning your own {b`Tempest\RateLimit\RateLimitStorage`} from `createStorage()`. + +## 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 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; `fake()` and `preventThrottling()` compose in either order. + +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 000000000..3f7775660 --- /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 000000000..54215b726 --- /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 000000000..dbe004ca5 --- /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 000000000..f0c39c212 --- /dev/null +++ b/packages/rate-limit/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + diff --git a/packages/rate-limit/src/Config/CacheRateLimitConfig.php b/packages/rate-limit/src/Config/CacheRateLimitConfig.php new file mode 100644 index 000000000..9dfb48d09 --- /dev/null +++ b/packages/rate-limit/src/Config/CacheRateLimitConfig.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): 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 new file mode 100644 index 000000000..03fa4b3f1 --- /dev/null +++ b/packages/rate-limit/src/Config/RateLimitConfig.php @@ -0,0 +1,39 @@ + + */ + 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; + + /** + * 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 000000000..2994bb77b --- /dev/null +++ b/packages/rate-limit/src/Config/RedisRateLimitConfig.php @@ -0,0 +1,48 @@ + */ + 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 new file mode 100644 index 000000000..c48f8671c --- /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 000000000..9c02b5362 --- /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 000000000..027b302a8 --- /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 000000000..ebc6a9a9f --- /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 000000000..228253893 --- /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 000000000..08f0709af --- /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 000000000..da634a73d --- /dev/null +++ b/packages/rate-limit/src/Http/ThrottleMiddleware.php @@ -0,0 +1,135 @@ +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 000000000..993c2b5f7 --- /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 000000000..44e30afaa --- /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 000000000..4b9f312d2 --- /dev/null +++ b/packages/rate-limit/src/Initializers/RateLimitStorageInitializer.php @@ -0,0 +1,20 @@ +get(RateLimitConfig::class)->createStorage($container); + } +} diff --git a/packages/rate-limit/src/Initializers/RateLimiterInitializer.php b/packages/rate-limit/src/Initializers/RateLimiterInitializer.php new file mode 100644 index 000000000..00ac1be4d --- /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 000000000..5dba03dbe --- /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 000000000..d03bb6f76 --- /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 000000000..be859fb44 --- /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 000000000..769f8bb2b --- /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 000000000..b2c8f8c50 --- /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 000000000..19a9a3812 --- /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 000000000..5f9c60576 --- /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 000000000..a2e7b4a3e --- /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 000000000..9fbb39b56 --- /dev/null +++ b/packages/rate-limit/src/Testing/RateLimitTester.php @@ -0,0 +1,171 @@ +container->get(Clock::class), + ); + + $this->container->singleton(RateLimitStorage::class, $storage); + + // 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; + } + + /** + * Allows every attempt without recording it. Counters are left as they were, so + * {@see self::allowThrottling()} resumes where enforcement stopped. + */ + public function preventThrottling(): self + { + $limiter = $this->limiter(); + + if (! $limiter instanceof UnlimitedRateLimiter) { + $this->container->singleton(RateLimiter::class, new UnlimitedRateLimiter($limiter)); + } + + return $this; + } + + /** + * Applies limits again, undoing {@see self::preventThrottling()}. + */ + public function allowThrottling(): self + { + $limiter = $this->limiter(); + + if ($limiter instanceof UnlimitedRateLimiter) { + $this->container->singleton(RateLimiter::class, $limiter->limiter); + } + + 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); + } + + private function isThrottlingPrevented(): bool + { + return $this->limiter() instanceof UnlimitedRateLimiter; + } +} diff --git a/packages/rate-limit/src/Testing/TestingRateLimitStorage.php b/packages/rate-limit/src/Testing/TestingRateLimitStorage.php new file mode 100644 index 000000000..9a6482fa3 --- /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/src/Testing/UnlimitedRateLimiter.php b/packages/rate-limit/src/Testing/UnlimitedRateLimiter.php new file mode 100644 index 000000000..6daa486f8 --- /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/packages/rate-limit/tests/RateLimiterTest.php b/packages/rate-limit/tests/RateLimiterTest.php new file mode 100644 index 000000000..e4b1b9ed6 --- /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 CacheRateLimitConfig(), + ), + 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 000000000..6b138e69b --- /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 f75538533..cffad4178 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 000000000..5d10b42d0 --- /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 000000000..4c2659645 --- /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 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 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 + { + $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 + { + $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 000000000..8af133f7b --- /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 = new RedisRateLimitConfig()->createStorage($this->container); + } + + #[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 000000000..d643ec850 --- /dev/null +++ b/tests/Integration/RateLimit/ThrottleMiddlewareTest.php @@ -0,0 +1,349 @@ +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 CacheRateLimitConfig(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 CacheRateLimitConfig(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_prevented_and_allowed_again(): 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); + } + + #[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(); + } +}