Skip to content

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

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

osbre wants to merge 14 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 the tempest/rate-limit package. It limits how often an action may happen within a window of time, using a fixed-window algorithm. The package is usable both from HTTP routes and from application code that needs to throttle an arbitrary operation.

Counters use the cache by default, so the package works without extra infrastructure. Applications serving concurrent traffic can switch to Redis, which increments counters atomically.

HTTP route throttling

#[Throttle]

Limits how often a route may be requested. The attribute is repeatable and can be applied to a controller method or to 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 every client gets its own counter. Naming a bucket makes routes sharing that bucket use one allowance per client.
  • Limits with different windows use separate counters. When several limits describe the same bucket and window, the smallest allowance applies.
  • On a controller, one allowance covers every route it exposes. Method-level limits apply on top rather than replacing it.
  • Multiple limits are evaluated from the shortest window to the longest. A rejected request does not consume later quotas.
  • Allowed responses carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Rejected ones get a 429 with Retry-After. Headers can be disabled with includeHeaders: false.

The attribute also accepts a profile for limits that depend on the request:

#[Throttle(profile: ApiRateLimitProfile::class)]

RateLimitProfile::resolve(Request): RateLimit[] may return several limits, or an empty array to leave the request unlimited. Unkeyed profile limits use the route or controller scope and are counted per client. Keyed limits created with withKey() use the key exactly as provided, so the same counter can be shared or inspected through the generic limiter.

Static limits and profiles are represented by the same Throttle attribute. Invalid combinations are rejected when the attribute is constructed, rather than being accepted and failing later in middleware.

RateLimitKeyResolver

Decides what identifies a client. It defaults to ClientIPKeyResolver, which normalizes IPv4 and IPv6-mapped addresses. Implement the interface and configure keyResolverClass 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. Applications behind a reverse proxy must configure trusted proxies before relying on client IP limits.

Failure handling

The cache backend can fail to acquire the lock for a counter under contention. In that case the request is rejected with 503 Service Unavailable rather than guessing whether it should be allowed or throttled. The error is surfaced through the normal HTTP exception rendering path, including the response headers.

RateLimiter

The limiter is not tied to HTTP. Inject RateLimiter 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(), or 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 limit throws RateLimitKeyWasMissing rather than sharing a counter with every other unkeyed limit.

RateLimitWasExceeded is the caller's to handle. It is not converted into a 429 by a global middleware: code using the generic limiter has opted into handling that operation's rejection itself. Callers can catch the exception or use attempt() and branch on the result.

Configuration and storage

CacheRateLimitConfig is the default discovered configuration. RedisRateLimitConfig switches the package to RedisRateLimitStorage:

// app/rateLimit.config.php
return new RedisRateLimitConfig();

RateLimitConfig controls the storage backend, key prefix, HTTP headers, and key resolver. Custom backends can implement RateLimitConfig::createStorage() and RateLimitStorage.

  • CacheRateLimitStorage stores a RateLimitState in the configured cache. Read-modify-write is serialized with locks, but the counter is only as durable as the cache.
  • RedisRateLimitStorage performs increment-and-open-window in one Lua script. It counts correctly under concurrency and needs no lock.
  • Both backends derive their storage key through the configured prefix and hash arbitrary application keys with xxh128.

Testing

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

$this->rateLimit->fake();

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

exhaust() and assertThrottled() cover rejected attempts. preventThrottling() temporarily allows every attempt without recording it, which is useful when testing unrelated behavior. HTTP tests can exercise throttled routes through simulated requests, while keyed profile limits can be asserted directly through RateLimiter.

Design decisions

Fixed windows

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. Attempts are recorded before evaluating the result, so a rejected attempt cannot 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. Sliding windows and token buckets are out of scope.

One storage contract

RateLimiter is the API surface to inject and call, while RateLimitStorage handles persistence. Keeping them separate means storage operations never leak into the public limiter API, and no storage backend is baked into application code. RateLimitState persists hits and the reset timestamp together, so a counter never exists without its window.

HTTP counter scoping

Throttle adds ThrottleMiddleware to decorated routes during discovery. The middleware resolves method- and controller-level attributes, derives a counter key from the declaration scope, allowance, window, bucket, and client, collapses duplicate limits, and applies the narrowest limits first. Named buckets are shared per client; keyed profile limits are the explicit escape hatch for counters shared across application code.

Comment thread packages/rate-limit/src/Http/ClientIpKeyResolver.php Outdated

@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.

Comment thread packages/rate-limit/src/Config/rateLimit.config.php
Comment thread packages/rate-limit/src/Http/ThrottleWith.php Outdated
Comment thread packages/rate-limit/src/Config/RateLimitConfig.php Outdated
Comment thread packages/rate-limit/src/Http/ThrottleMiddleware.php Outdated
@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.

@innocenzi
innocenzi marked this pull request as draft September 7, 2026 17:06

@xHeaven xHeaven 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.

I've added a couple failing tests as well, please fix them.

Comment thread packages/rate-limit/src/RateLimitException.php Outdated
Comment thread packages/rate-limit/src/RateLimitHasNoKey.php Outdated
Comment thread packages/rate-limit/src/Storage/RateLimitStorageFailed.php Outdated
@osbre
osbre marked this pull request as ready for review September 12, 2026 03:11
@aidan-casey

Copy link
Copy Markdown
Member

Hey, @osbre! Thanks for putting the work into this.

This PR is too large for us to properly review and merge as-is, and there are also a few things that aren’t working as we’d expect.

I’d suggest breaking this down into smaller, focused contributions. And if you’re using AI to assist with future contributions, please disclose that in the PR.

For now, I’m going to close this one. Thanks again for your contributions thus far!

@tempestphp tempestphp locked and limited conversation to collaborators Sep 12, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants