Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
270 changes: 270 additions & 0 deletions docs/2-features/21-rate-limiting.md
Original file line number Diff line number Diff line change
@@ -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`}.
14 changes: 14 additions & 0 deletions packages/rate-limit/.gitattributes
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions packages/rate-limit/LICENSE.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions packages/rate-limit/composer.json
Original file line number Diff line number Diff line change
@@ -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"
}
23 changes: 23 additions & 0 deletions packages/rate-limit/phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.4/phpunit.xsd"
bootstrap="vendor/autoload.php"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="false"
failOnRisky="true"
failOnWarning="true"
>
<testsuites>
<testsuite name="Tempest Rate Limit">
<directory>tests</directory>
</testsuite>
</testsuites>
<source restrictNotices="true" restrictWarnings="true" ignoreIndirectDeprecations="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
Loading
Loading