Conversation
innocenzi
left a comment
There was a problem hiding this comment.
Looks pretty good overall, I like the API. Not a full review, just a few nitpicks.
|
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. |
xHeaven
left a comment
There was a problem hiding this comment.
I've added a couple failing tests as well, please fix them.
|
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! |
This PR adds the
tempest/rate-limitpackage. 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.
perandeverywiden it, as inper: Per::MINUTE, every: 5.bucketmakes routes sharing that bucket use one allowance per client.X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset. Rejected ones get a429withRetry-After. Headers can be disabled withincludeHeaders: false.The attribute also accepts a
profilefor limits that depend on the request: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 withwithKey()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
Throttleattribute. Invalid combinations are rejected when the attribute is constructed, rather than being accepted and failing later in middleware.RateLimitKeyResolverDecides what identifies a client. It defaults to
ClientIPKeyResolver, which normalizes IPv4 and IPv6-mapped addresses. Implement the interface and configurekeyResolverClassto count by authenticated user, API key, or anything else.Returning
nullputs 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 Unavailablerather than guessing whether it should be allowed or throttled. The error is surfaced through the normal HTTP exception rendering path, including the response headers.RateLimiterThe limiter is not tied to HTTP. Inject
RateLimiterand count attempts against any key:RateLimit::perSecond(),perMinute(),perHour(), orperDay(), each taking an optional multiplier.withKey()scopes a limit, andscopedTo()appends to an existing key.attempt()records an attempt, or several withby:, and returns aRateLimitResultexposingallowed,exceeded,limit,hits,remaining,retryAfter, andresetsAt.peek()inspects without consuming, andclear()discards recorded attempts.throttle()runs a callback only when the limit allows it, throwingRateLimitWasExceededotherwise.RateLimitKeyWasMissingrather than sharing a counter with every other unkeyed limit.RateLimitWasExceededis the caller's to handle. It is not converted into a429by a global middleware: code using the generic limiter has opted into handling that operation's rejection itself. Callers can catch the exception or useattempt()and branch on the result.Configuration and storage
CacheRateLimitConfigis the default discovered configuration.RedisRateLimitConfigswitches the package toRedisRateLimitStorage:RateLimitConfigcontrols the storage backend, key prefix, HTTP headers, and key resolver. Custom backends can implementRateLimitConfig::createStorage()andRateLimitStorage.CacheRateLimitStoragestores aRateLimitStatein the configured cache. Read-modify-write is serialized with locks, but the counter is only as durable as the cache.RedisRateLimitStorageperforms increment-and-open-window in one Lua script. It counts correctly under concurrency and needs no lock.xxh128.Testing
RateLimitTesteris available onIntegrationTestas$this->rateLimit.fake()swaps in isolated in-memory storage, so tests need neither Redis nor a configured cache, and counters cannot leak between tests.exhaust()andassertThrottled()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 throughRateLimiter.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 at11:59:59and 60 more at12:00:00are both legal. Sliding windows and token buckets are out of scope.One storage contract
RateLimiteris the API surface to inject and call, whileRateLimitStoragehandles persistence. Keeping them separate means storage operations never leak into the public limiter API, and no storage backend is baked into application code.RateLimitStatepersists hits and the reset timestamp together, so a counter never exists without its window.HTTP counter scoping
ThrottleaddsThrottleMiddlewareto 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.