[#290] Add first-class route rate limiting with global backend#507
Conversation
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds per-route rate limiting: a RateLimitAdapter contract, file and Redis adapters, RateLimiter + factory, RateLimitMiddleware enforced at a new framework middleware stage, Route/RouteBuilder DSL/storage, unit tests, and CHANGELOG updates. ChangesRoute-Level Rate Limiting with Adapter Architecture
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d6c396f83
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #507 +/- ##
============================================
+ Coverage 90.70% 90.81% +0.11%
- Complexity 2930 2997 +67
============================================
Files 256 262 +6
Lines 7711 7906 +195
============================================
+ Hits 6994 7180 +186
- Misses 717 726 +9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
tests/Unit/Router/RouteTest.php (1)
128-143: ⚡ Quick winAdd boundary tests for invalid rate-limit values.
These tests cover happy paths, but not invalid inputs (e.g.,
limit <= 0,interval <= 0). Adding those cases will lock expected behavior and prevent regressions once validation is enforced.Also applies to: 191-205
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/Unit/Router/RouteTest.php` around lines 128 - 143, Add new unit tests in RouteTest to assert that invalid rate-limit inputs are rejected: create tests that call Route::rateLimit with limit <= 0 and interval <= 0 (and both invalid) and assert the expected failure behavior (e.g., exception thrown or null/empty result depending on project convention). Update or add test methods alongside testRouteRateLimitConfigurationIsStored and the similar block later to cover each invalid case separately, referencing Route::rateLimit and Route::getRateLimit to verify state does not accept invalid values. Ensure the tests assert the concrete expected outcome your codebase uses for validation (specific exception class or return value) so future changes will fail the tests if invalid values are allowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/RateLimit/Adapters/FileRateLimitAdapter.php`:
- Around line 37-54: The current read-modify-write in FileRateLimitAdapter.php
(using $this->cache->get($key) then local ++ then $this->cache->set(...)) is
racy; change it to an atomic update: use your cache client's atomic increment
(e.g. $this->cache->increment or Redis INCR) on $key to bump the count and set
the TTL/expiration for reset_at in the same atomic operation, ensuring $resetAt
is computed once and stored alongside the counter; if the cache client has no
atomic increment, implement a CAS/compare-and-set loop or obtain a short-lived
lock around the increment/reset logic (using the cache's lock facility) so
updates to $key (and fields 'count' and 'reset_at') cannot be lost under
concurrency.
- Around line 34-41: In FileRateLimitAdapter::hit validate inputs at the start
of the method: check that $limit and $interval are integers >= 1 and throw an
InvalidArgumentException (or similar) if either is < 1 so the method fails fast
instead of producing fallback windows; update the hit(string $key, int $limit,
int $interval) implementation to perform these checks before computing $now,
reading $data, or setting $resetAt.
In `@src/RateLimit/Adapters/RedisRateLimitAdapter.php`:
- Around line 34-57: The hit method in RedisRateLimitAdapter currently does a
non-atomic get→modify→set sequence (in hit), which allows lost increments under
concurrency; change it to perform an atomic increment and set the TTL only when
the key is first created by using Redis primitives (INCR + EXPIRE or a small Lua
script that INCRs and calls EXPIRE when count==1). If Quantum\Cache\Cache
doesn't expose raw Redis commands, inject a raw Redis client (e.g., \Redis or
\Predis\Client) into RedisRateLimitAdapter and use that client to execute the
atomic INCR and conditional EXPIRE/EVAL; ensure hit returns count <= $limit
based on the atomic result and that the TTL equals the provided $interval.
In `@src/RateLimit/RateLimitMiddleware.php`:
- Line 62: The middleware sets Retry-After using the full window ($interval)
rather than time left; update the rate limiter interface and implementations to
provide the remaining time and use that in RateLimitMiddleware: add a new method
remainingSeconds(string $key): int to RateLimitAdapterInterface (keeping hit():
bool unchanged for compatibility), implement remainingSeconds in
FileRateLimitAdapter and RedisRateLimitAdapter to return max(0, reset_at -
time()), and modify RateLimitMiddleware (the code that currently calls
setHeader('Retry-After', (string) $interval)) to call remainingSeconds($key) and
use that value for the Retry-After header so clients receive the actual
remaining window seconds.
In `@src/Router/Route.php`:
- Around line 155-160: The rateLimit method currently accepts non-positive
integers; update Route::rateLimit to validate that both $limit and $interval are
positive (>0) and throw an InvalidArgumentException (or similar) when either is
non-positive, before assigning $this->rateLimit; include a clear message
referencing the invalid parameter name so callers know which argument failed
validation.
---
Nitpick comments:
In `@tests/Unit/Router/RouteTest.php`:
- Around line 128-143: Add new unit tests in RouteTest to assert that invalid
rate-limit inputs are rejected: create tests that call Route::rateLimit with
limit <= 0 and interval <= 0 (and both invalid) and assert the expected failure
behavior (e.g., exception thrown or null/empty result depending on project
convention). Update or add test methods alongside
testRouteRateLimitConfigurationIsStored and the similar block later to cover
each invalid case separately, referencing Route::rateLimit and
Route::getRateLimit to verify state does not accept invalid values. Ensure the
tests assert the concrete expected outcome your codebase uses for validation
(specific exception class or return value) so future changes will fail the tests
if invalid values are allowed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1e677e8c-1ab8-4bc6-9e58-03131dbd6bb3
📒 Files selected for processing (27)
CHANGELOG.mdsrc/Middleware/MiddlewareManager.phpsrc/RateLimit/Adapters/FileRateLimitAdapter.phpsrc/RateLimit/Adapters/RedisRateLimitAdapter.phpsrc/RateLimit/Contracts/RateLimitAdapterInterface.phpsrc/RateLimit/Enums/ExceptionMessages.phpsrc/RateLimit/Enums/RateLimitType.phpsrc/RateLimit/Exceptions/RateLimitException.phpsrc/RateLimit/Factories/RateLimiterFactory.phpsrc/RateLimit/RateLimitMiddleware.phpsrc/RateLimit/RateLimiter.phpsrc/Router/Enums/ExceptionMessages.phpsrc/Router/Exceptions/RouteException.phpsrc/Router/Route.phpsrc/Router/RouteBuilder.phptests/Helpers/InMemoryPsrCache.phptests/Unit/Middleware/MiddlewareManagerTest.phptests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.phptests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.phptests/Unit/RateLimit/Exceptions/RateLimitExceptionTest.phptests/Unit/RateLimit/Factories/RateLimiterFactoryTest.phptests/Unit/RateLimit/RateLimitMiddlewareTest.phptests/Unit/RateLimit/RateLimiterTest.phptests/Unit/Router/Exceptions/RouteExceptionTest.phptests/Unit/Router/RouteBuilderTest.phptests/Unit/Router/RouteTest.phptests/_root/shared/config/rate_limit.php
There was a problem hiding this comment.
🧹 Nitpick comments (3)
tests/Unit/RateLimit/RateLimiterTest.php (1)
11-58: ⚡ Quick win
buildKeytests don't cover whitespace trimming formethodorip.
testRateLimiterBuildsNormalizedKeyonly exercises route trimming (' /api/posts '). Bothmethodandipare passed without surrounding whitespace, so their trim behavior (which the AI summary and production code apparently implement) is never asserted. A missed trim on either field would silently produce wrong keys (and thus separate buckets per extra space), which is easy to miss.Consider adding a case alongside the existing normalization test:
$this->assertSame( 'POST:/api/posts:127.0.0.1', $limiter->buildKey('post', ' /api/posts ', '127.0.0.1') ); + +$this->assertSame( + 'POST:/api/posts:127.0.0.1', + $limiter->buildKey(' post ', ' /api/posts ', ' 127.0.0.1 ') +);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/Unit/RateLimit/RateLimiterTest.php` around lines 11 - 58, The test suite misses asserting that RateLimiter::buildKey trims whitespace for the method and IP parts; update Test class RateLimiterTest by extending testRateLimiterBuildsNormalizedKey (or add a new small assertion) to call $limiter->buildKey with padded method and ip (e.g., ' POST ' and ' 127.0.0.1 ') and assert the normalized result equals 'POST:/api/posts:127.0.0.1', ensuring buildKey's trimming behavior for method and ip is covered.tests/Unit/RateLimit/RateLimitMiddlewareTest.php (2)
25-28: ⚖️ Poor tradeoffConsider using dependency injection instead of reflection.
Mutating the factory's private
instancesproperty via reflection couples the test to implementation details and bypasses proper encapsulation. If the factory provides constructor injection or a setter method for testing, prefer that approach. Additionally, consider adding atearDown()method to reset the factory state between tests to prevent state leakage.♻️ Alternative approach using factory method or setter
If
RateLimiterFactorysupports it, inject the adapter directly:-$factory = Di::get(RateLimiterFactory::class); -$this->setPrivateProperty($factory, 'instances', [ - 'file' => new RateLimiter(new ToggleAdapter()), -]); +// Option 1: If factory supports setting adapter +$factory = Di::get(RateLimiterFactory::class); +$factory->setAdapter('file', new ToggleAdapter()); + +// Option 2: Register custom factory for testing +Di::register(RateLimiterFactory::class, fn() => + new RateLimiterFactory(['file' => new RateLimiter(new ToggleAdapter())]) +);And add cleanup:
public function tearDown(): void { // Reset factory state parent::tearDown(); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/Unit/RateLimit/RateLimitMiddlewareTest.php` around lines 25 - 28, Tests currently mutate RateLimiterFactory::instances via setPrivateProperty which couples tests to internals; instead, obtain a RateLimiterFactory instance through the Di container and inject a test instance (RateLimiter with ToggleAdapter) using a public setter or factory method on RateLimiterFactory (or register a test binding in Di to return a preconfigured RateLimiterFactory), replace the setPrivateProperty usage in the test, and add a tearDown() that resets the factory state or rebinds the original Di registration to avoid state leakage between tests; refer to RateLimiterFactory, instances, RateLimiter, ToggleAdapter and the test's tearDown for changes.
31-58: 🏗️ Heavy liftTest doesn't verify rate-limit key construction.
The
ToggleAdapterignores the$keyparameter (lines 65-68), so this test doesn't verify that the middleware constructs keys using the required format: HTTP method + route pattern + client IP. According to the PR objectives, the key shape is fixed and must include all three components.💡 Suggested enhancement to verify key construction
Modify
ToggleAdapterto capture and expose the key:class ToggleAdapter implements RateLimitAdapterInterface { private int $calls = 0; + public ?string $lastKey = null; public function hit(string $key, int $limit, int $interval): bool { + $this->lastKey = $key; $this->calls++; return $this->calls === 1; }Then add assertions in the test:
$adapter = new ToggleAdapter(); $factory->setAdapter('file', new RateLimiter($adapter)); // ... apply middleware ... $this->assertStringContainsString('GET', $adapter->lastKey); $this->assertStringContainsString('/posts', $adapter->lastKey); // IP assertion would depend on how request() populates client IP🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/Unit/RateLimit/RateLimitMiddlewareTest.php` around lines 31 - 58, The test currently doesn't verify the constructed rate-limit key because the ToggleAdapter swallows the $key; modify the ToggleAdapter (used by RateLimiter) to store the last received key in a public property (e.g., lastKey) whenever its increment/check methods are called, then in testRateLimitMiddlewareBlocksWith429AndHeaders instantiate that ToggleAdapter, register it with the factory via factory->setAdapter('file', new RateLimiter($adapter)), run the middleware apply calls as before, and add assertions against $adapter->lastKey to assert it contains the HTTP method ("GET"), the route pattern ("/posts") and the client IP (as provided by request()), so the test validates the key shape produced by RateLimitMiddleware/RateLimiter.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/Unit/RateLimit/RateLimiterTest.php`:
- Around line 11-58: The test suite misses asserting that RateLimiter::buildKey
trims whitespace for the method and IP parts; update Test class RateLimiterTest
by extending testRateLimiterBuildsNormalizedKey (or add a new small assertion)
to call $limiter->buildKey with padded method and ip (e.g., ' POST ' and '
127.0.0.1 ') and assert the normalized result equals
'POST:/api/posts:127.0.0.1', ensuring buildKey's trimming behavior for method
and ip is covered.
In `@tests/Unit/RateLimit/RateLimitMiddlewareTest.php`:
- Around line 25-28: Tests currently mutate RateLimiterFactory::instances via
setPrivateProperty which couples tests to internals; instead, obtain a
RateLimiterFactory instance through the Di container and inject a test instance
(RateLimiter with ToggleAdapter) using a public setter or factory method on
RateLimiterFactory (or register a test binding in Di to return a preconfigured
RateLimiterFactory), replace the setPrivateProperty usage in the test, and add a
tearDown() that resets the factory state or rebinds the original Di registration
to avoid state leakage between tests; refer to RateLimiterFactory, instances,
RateLimiter, ToggleAdapter and the test's tearDown for changes.
- Around line 31-58: The test currently doesn't verify the constructed
rate-limit key because the ToggleAdapter swallows the $key; modify the
ToggleAdapter (used by RateLimiter) to store the last received key in a public
property (e.g., lastKey) whenever its increment/check methods are called, then
in testRateLimitMiddlewareBlocksWith429AndHeaders instantiate that
ToggleAdapter, register it with the factory via factory->setAdapter('file', new
RateLimiter($adapter)), run the middleware apply calls as before, and add
assertions against $adapter->lastKey to assert it contains the HTTP method
("GET"), the route pattern ("/posts") and the client IP (as provided by
request()), so the test validates the key shape produced by
RateLimitMiddleware/RateLimiter.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4ecd51d6-f596-4457-bd33-f102772f51f3
📒 Files selected for processing (10)
src/RateLimit/Adapters/FileRateLimitAdapter.phpsrc/RateLimit/Adapters/RedisRateLimitAdapter.phpsrc/RateLimit/Contracts/RateLimitAdapterInterface.phpsrc/RateLimit/Factories/RateLimiterFactory.phpsrc/RateLimit/RateLimitMiddleware.phpsrc/RateLimit/RateLimiter.phptests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.phptests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.phptests/Unit/RateLimit/RateLimitMiddlewareTest.phptests/Unit/RateLimit/RateLimiterTest.php
✅ Files skipped from review due to trivial changes (2)
- src/RateLimit/RateLimitMiddleware.php
- tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php
🚧 Files skipped from review as they are similar to previous changes (6)
- src/RateLimit/Contracts/RateLimitAdapterInterface.php
- src/RateLimit/RateLimiter.php
- tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php
- src/RateLimit/Adapters/RedisRateLimitAdapter.php
- src/RateLimit/Factories/RateLimiterFactory.php
- src/RateLimit/Adapters/FileRateLimitAdapter.php
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.php`:
- Around line 92-95: The test in FileRateLimitAdapterTest writes an invalid JSON
file using a hardcoded filename pattern md5('test' . 'broken') . '.rate' which
mirrors the adapter's current key scheme and can lead to a false positive if the
adapter's naming changes; change the test to stop relying on the adapter's
internal filename: either (A) use a test-only helper on the adapter to obtain
the exact state filename for key "broken" (e.g., call
adapter->stateFileForKey('broken') or similar) and write the invalid JSON to
that path before asserting adapter->retryAfter('broken') === 0, or (B) add an
explicit comment documenting the assumed filename scheme and derive the filename
via a public method or protected accessor exposed only for tests so the test
targets the adapter's actual file naming (referencing FileRateLimitAdapterTest,
retryAfter, and the adapter instance).
- Around line 63-80: The testFileAdapterCreatesStorageDirectoryWhenMissing test
can leak the rate_limit_tests_missing directory if the assertion fails; update
the test to ensure cleanup always runs by wrapping the adapter creation and
assertion in a try/finally block so fs()->removeDirectory($path) is called in
the finally clause; reference the local $path variable, fs() helper and the
FileRateLimitAdapter instantiation so the directory is removed regardless of
assertion outcome.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b3ee8e12-b2ef-4b67-953e-ef6631bf3a8f
📒 Files selected for processing (16)
src/Middleware/MiddlewareManager.phpsrc/RateLimit/Adapters/FileRateLimitAdapter.phpsrc/RateLimit/Adapters/RedisRateLimitAdapter.phpsrc/RateLimit/Contracts/RateLimitAdapterInterface.phpsrc/RateLimit/Enums/ExceptionMessages.phpsrc/RateLimit/Exceptions/RateLimitException.phpsrc/RateLimit/Factories/RateLimiterFactory.phpsrc/RateLimit/RateLimitMiddleware.phpsrc/RateLimit/RateLimiter.phpsrc/Router/Route.phptests/Unit/RateLimit/Adapters/FileRateLimitAdapterTest.phptests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.phptests/Unit/RateLimit/RateLimitMiddlewareTest.phptests/Unit/Router/RouteBuilderTest.phptests/Unit/Router/RouteTest.phptests/_root/shared/config/rate_limit.php
✅ Files skipped from review due to trivial changes (3)
- src/RateLimit/Enums/ExceptionMessages.php
- src/RateLimit/Contracts/RateLimitAdapterInterface.php
- src/RateLimit/RateLimiter.php
🚧 Files skipped from review as they are similar to previous changes (7)
- tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php
- src/RateLimit/Exceptions/RateLimitException.php
- src/RateLimit/RateLimitMiddleware.php
- src/Router/Route.php
- src/Middleware/MiddlewareManager.php
- src/RateLimit/Adapters/FileRateLimitAdapter.php
- src/RateLimit/Adapters/RedisRateLimitAdapter.php
Closes #290
Summary by CodeRabbit
New Features
Documentation
Tests