Skip to content

feat(rate-limit): add rate limiting - #2272

Open
osbre wants to merge 5 commits into
tempestphp:3.xfrom
osbre:feat/rate-limiting
Open

feat(rate-limit): add rate limiting#2272
osbre wants to merge 5 commits into
tempestphp:3.xfrom
osbre:feat/rate-limiting

Conversation

@osbre

@osbre osbre commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This PR adds a tempest/rate-limit package. It limits how often an action may happen within a window of time, using a fixed window algorithm. The design was initially inspired by Laravel's rate limiter.

Counters live in the cache by default, so the package works out of the box with no extra infrastructure. Applications serving concurrent traffic can switch to Redis, which counts atomically.

Usage

#[Throttle]

Limits how often a route may be requested. The attribute is repeatable, and is valid on both a controller method and the controller itself.

#[Throttle(attempts: 20)]
#[Throttle(attempts: 1000, per: Per::DAY)]
#[Get('/api/posts')]
public function index(): Response { /* … */ }
  • The window defaults to one minute. per and every widen it, as in per: Per::MINUTE, every: 5.
  • Every route gets its own counter, and within a route every client gets its own. Naming a bucket scopes the limit to the client and nothing else, so several routes naming the same bucket share one allowance.
  • Two attributes describing the same allowance describe one limit, not two. They land in the same counter and are spent once per request.
  • On a controller, one allowance covers every route it exposes. Method-level limits apply on top rather than replacing it.
  • Allowed responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Rejected ones get a 429 with Retry-After. Both can be switched off with includeHeaders: false.

#[ThrottleWith] and RateLimitProfile

For limits that depend on the request itself, such as a higher allowance for paying customers and none at all for internal traffic. resolve(Request): RateLimit[] may return several limits, or an empty array to leave the request unlimited.

#[ThrottleWith(ApiRateLimitProfile::class)]

Limits a profile returns are scoped exactly like the ones #[Throttle] declares. Unkeyed, they get a counter per route and per client. Keyed with withKey(), they become a shared bucket.

#[Throttle] and #[ThrottleWith] are separate attributes rather than one attribute with mutually exclusive arguments. A constructor cannot be handed a combination the component has to reject at runtime. Both implement Throttles, which is what puts the middleware on the route and what the middleware collects.

RateLimitKeyResolver

Decides what is being counted. It defaults to ClientIpKeyResolver. Implement the interface and point keyResolverClass at it to count by authenticated user, API key, or anything else. Returning null puts the request in a shared "unidentified" bucket, so an unidentifiable client never escapes its limit.

RateLimiter

The limiter is not tied to HTTP. Inject it and count attempts against any key.

$limit = RateLimit::perHour(3)->withKey("verification-email:{$user->id}");

if ($this->limiter->attempt($limit)->exceeded) {
    return;
}
  • Limits are built with RateLimit::perSecond(), perMinute(), perHour() and perDay(), each taking an optional multiplier. withKey() scopes a limit, and scopedTo() appends to an existing key.
  • attempt() records an attempt, or several with by:, and returns a RateLimitResult exposing allowed, exceeded, limit, hits, remaining, retryAfter and resetsAt.
  • peek() inspects without consuming, and clear() discards recorded attempts. throttle() runs a callback only when the limit allows it, throwing RateLimitWasExceeded otherwise.
  • A limit must carry a key by the time it reaches the limiter. An unkeyed one is rejected rather than guessed at, as it would share a counter with every other unkeyed limit.
  • RateLimitWasExceeded is the caller's to handle. It is not converted into a 429 by the framework: the throw is how throttle() reports a rejection, and code reaching for the limiter by hand has already opted out of #[Throttle]. A global middleware translating it would run on every request in every application to catch an exception most never throw, and would silently answer for callers who never asked it to. Catch it, or use attempt() and branch on the result.

RateLimitStorage

Where windows are persisted. CacheRateLimitStorage (the default) and RedisRateLimitStorage ship with the package. Point storageClass at your own implementation to store windows anywhere else.

Configuration

RateLimitConfig ships as a discovered config file and holds everything above:

return new RateLimitConfig(
    storageClass: RedisRateLimitStorage::class,
    keyResolverClass: ApiKeyResolver::class,
    includeHeaders: true,
    keyPrefix: 'rate_limit',
    lockTimeoutInSeconds: 5,
);

lockTimeoutInSeconds only applies to the cache storage, which is the one that needs a lock.

Testing

RateLimitTester is available on IntegrationTest as $this->rateLimit. fake() swaps in in-memory storage, so tests need neither Redis nor a cache, and counters cannot leak between them.

$this->rateLimit->fake();

$this->rateLimit
    ->hit($limit, times: 2)
    ->assertHits($limit, 2)
    ->assertRemaining($limit, 1)
    ->assertNotThrottled($limit);

Design decisions

The algorithm

A window opens on the first attempt, and its end is fixed at that moment. Later attempts increment the counter but never push the end out, so hammering a limit cannot extend it. Once the window elapses the counter is gone, through cache expiry or Redis TTL, and the next attempt opens a fresh one. attempt() records before evaluating, so a rejected attempt can never nudge the window.

The known trade-off is the boundary burst. Under perMinute(60), 60 requests at 11:59:59 and 60 more at 12:00:00 are both legal. This is inherent to the fixed window approach.

Sliding window and token bucket are out of scope. The storage contract is fixed-window-shaped, so adding them later means changing RateLimitStorage, not just adding a RateLimiter.

Two interfaces

RateLimiter is the API surface, to inject and call, following the Generic* convention used across the framework. RateLimitStorage is used for persistence, with three implementations and a config knob. Keeping them apart means find, increment and remove never leak into what users type-hint, and no storage backend is baked into the API.

RateLimitState is what gets persisted: hits plus resetsAtInSeconds in a single value. A counter never exists without the window it belongs to.

Storage

  • CacheRateLimitStorage (the default) works on any configured cache. Read-modify-write is not atomic there, so each increment takes a lock. It may undercount under simultaneous arrivals, and it is only as durable as the cache itself.
  • RedisRateLimitStorage does increment-and-open-window in one Lua script. It counts correctly under concurrency and needs no lock. A window opens only when the TTL is negative, not when hits reset, so a reset counter does not get a second window.

Redis scripts are sent with EVAL rather than cached with EVALSHA. They are a couple of hundred bytes, and the clients Tempest supports disagree on how a missing script is signalled.

Both implementations derive their storage key through RateLimitConfig::storageKey(), which applies the configured prefix and hashes with xxh128. A limit scoped to something unbounded cannot produce a key the store rejects.

The HTTP path

Throttles extends RouteDecorator, following the existing Idempotent attribute. At discovery it appends ThrottleMiddleware to the route's middleware. The middleware is #[SkipDiscovery], which means it is never constructed for unthrottled routes, it shows up in the route's own middleware list for debugging, and #[WithoutMiddleware] disables an inherited controller-wide throttle.

At request time the middleware:

  1. Reads Throttles attributes from the handler method and its declaring class, and asks each to resolve the limits it subjects the request to.
  2. Hands each limit to ThrottleCounterKey, which decides the counter it is spent from. A named bucket is scoped to the client alone. An unnamed one is scoped to the declaring class, the scope, the allowance it describes, and the client, plus the method and URI for route-scope limits. Controller scope omits those two, so every route it covers shares one counter. Identified clients are prefixed, so no resolver can return a value landing in the unidentified bucket. Limits landing in the same counter are collapsed, since one allowance declared twice is one limit.
  3. Sorts limits narrowest window first and consumes them one at a time. The first rejection stops the rest, so hammering a per-minute limit cannot also spend the daily allowance collecting 429s. The outcome does not depend on attribute declaration order.
  4. On rejection, throws HttpRequestFailed with the status and headers. Error responses are re-rendered from scratch, so the headers ride on the exception rather than being set on a response that would be discarded. This is what feat(router): allow error responses to carry headers #2267 enabled.
  5. On success, attaches the headers of the most constrained result to the response.

* Counts requests by client IP. Requires {@see \Tempest\Http\Ip\TrustedProxiesConfig}
* for reliable client IPs behind proxies.
*/
final readonly class ClientIpKeyResolver implements RateLimitKeyResolver

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you update the acronym casing to conform to our guidelines?

@osbre osbre Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this guideline on acronym casing, was https://github.com/tempestphp/tempest-framework/blob/3.x/packages/support/src/Ip/IpAddress.php from #2244 also meant to be named IPAddress? 👀 I suppose now we need to rename it as well, and put it out as a breaking change with a Rector rule for easy upgrade, or maybe that's for 4.x?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh... yes. 🫣😛

Let me create an issue for it and we'll track to 4.x, I think.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the purpose of this PR, let's rename though.

@innocenzi innocenzi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good overall, I like the API. Not a full review, just a few nitpicks.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's name it rate-limit.config.php

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gussing the current rule goes that framework-owned configuration files are camelCase, whereas user-made ones are kebab-case?

Or we want to eventually move the framework-owned configs to be kebab-case as well?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, we need to actually come to an agreement and document the convention here. Let's forget about that for now, we can always change it later, it's not gonna be a breaking change.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@osbre you're bringing us to shame with our inconsistencies here. 🙈😅

* ```
*/
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final readonly class ThrottleWith implements Throttles

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just add a profile property to Throttle? Would avoid to remember what both attributes do, and would avoid the awkward trait as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my initial idea. I split it because merging makes attempts nullable and needs a runtime check for the invalid combinations (neither set, both set, or profile combined with per/every/bucket) plus an exception type for it - whereas two attributes make those states unrepresentable by splitting responsibilities.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. In my opinion the runtime check is worth the improved DX though. Any opinion here @aidan-casey?

*
* @var class-string<RateLimitStorage>
*/
public string $storageClass = CacheRateLimitStorage::class,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have an interface for the config, and add a dedicated CacheRateLimitConfig implementation. We use that pattern in many places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brilliant, thank you! Now we can also move lockTimeoutInSeconds into a Cache-specific config 👍


public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
{
if (! $this->config->enabled) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the advantage of having this vs. just not adding the middleware?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was there to make preventThrottling() and allowThrottling() work in testing. I just realised we could achieve the same with UnlimitedRateLimiter as an alternative to the config parameter: a09d24c (this PR)

@aidan-casey

Copy link
Copy Markdown
Member

Nice work here! Like @innocenzi, I like the API.

More of a side note, I am expecting we may have to consider potential edge cases with worker mode. There's a chance it may not be an issue given the storage drivers, but just making a mental note.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants