diff --git a/src/Traits/HasRateLimits.php b/src/Traits/HasRateLimits.php index eba3f7d..3dbcd9d 100644 --- a/src/Traits/HasRateLimits.php +++ b/src/Traits/HasRateLimits.php @@ -10,6 +10,7 @@ use Saloon\Enums\PipeOrder; use Saloon\Http\PendingRequest; use Saloon\RateLimitPlugin\Limit; +use Saloon\Http\Faking\MockResponse; use Saloon\RateLimitPlugin\Helpers\LimitHelper; use Saloon\RateLimitPlugin\Contracts\RateLimitStore; use Saloon\RateLimitPlugin\Helpers\RetryAfterHelper; @@ -47,6 +48,15 @@ public function bootHasRateLimits(PendingRequest $pendingRequest): void // the request from being processed. $pendingRequest->middleware()->onRequest(function (PendingRequest $pendingRequest): void { + // Skip the pre-request limit check for cache hits. Cache middleware registers + // with PipeOrder::FIRST, so hasFakeResponse() is already true here (null order). + // Plain FakeResponse = cache hit; MockResponse = mock client (should still check). + $fakeResponse = $pendingRequest->getFakeResponse(); + + if ($fakeResponse !== null && ! $fakeResponse instanceof MockResponse) { + return; + } + $limit = $this->getExceededLimit(); if ($limit instanceof Limit) { @@ -55,6 +65,20 @@ public function bootHasRateLimits(PendingRequest $pendingRequest): void }); $pendingRequest->middleware()->onResponse(function (Response $response): Response { + // Skip rate limit counting for cache hits — they never reach the API. + // + // We cannot use $response->isCached() here because the cache plugin registers + // setCached(true) via onResponse(PipeOrder::FIRST) during the request middleware + // phase, which places it *after* this handler in the pipeline (same PipeOrder, + // later insertion). So isCached() is always false at this point. + // + // Instead: cache hits produce a plain FakeResponse, while MockClient produces a + // MockResponse (extends FakeResponse) and sets isMocked() before the pipeline runs. + // Checking hasFakeResponse() && !isMocked() isolates cache hits only. + if ($response->getPendingRequest()->hasFakeResponse() && ! $response->isMocked()) { + return $response; + } + $limitThatWasExceeded = null; $store = $this->rateLimitStore(); diff --git a/tests/Feature/CacheHitRateLimitTest.php b/tests/Feature/CacheHitRateLimitTest.php new file mode 100644 index 0000000..9767c24 --- /dev/null +++ b/tests/Feature/CacheHitRateLimitTest.php @@ -0,0 +1,125 @@ +middleware()->onRequest( + callable: function (PendingRequest $pendingRequest): FakeResponse { + return new FakeResponse(['cached' => true], 200); + }, + order: PipeOrder::FIRST, + ); +} + +test('cache hits do not count against the rate limit', function () { + $store = new MemoryStore; + + $connector = new TestConnector($store, [ + Limit::allow(3)->everyMinute(), + ]); + + withCacheMiddleware($connector); + + // Send 10 requests — all served from "cache". Limit of 3 should never be reached. + for ($i = 0; $i < 10; $i++) { + $connector->send(new UserRequest); + } + + expect($connector->hasReachedRateLimit())->toBeFalse(); +}); + +test('cache hits do not increment the hit counter in the store', function () { + $store = new MemoryStore; + + $connector = new TestConnector($store, [ + Limit::allow(3)->everyMinute(), + ]); + + withCacheMiddleware($connector); + + $connector->send(new UserRequest); + $connector->send(new UserRequest); + $connector->send(new UserRequest); + + // No real hits recorded — store stays empty + expect($store->getStore())->toBeEmpty(); +}); + +test('real api responses still count against the rate limit', function () { + $store = new MemoryStore; + + $connector = new TestConnector($store, [ + Limit::allow(3)->everyMinute(), + ]); + + $connector->withMockClient(new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ])); + + $connector->send(new UserRequest); + $connector->send(new UserRequest); + $connector->send(new UserRequest); + + expect($connector->hasReachedRateLimit())->toBeTrue(); +}); + +test('cache hits do not throw when the rate limit is already exhausted by real requests', function () { + $store = new MemoryStore; + + $connector = new TestConnector($store, [ + Limit::allow(3)->everyMinute(), + ]); + + // Exhaust the limit with 3 real (mock) requests + $connector->withMockClient(new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ])); + + $connector->send(new UserRequest); + $connector->send(new UserRequest); + $connector->send(new UserRequest); + + expect($connector->hasReachedRateLimit())->toBeTrue(); + + // Cache middleware must use PipeOrder::FIRST so it fires before the rate limit + // onRequest check — mirroring how the real cache plugin registers its middleware. + withCacheMiddleware($connector); + + expect(fn () => $connector->send(new UserRequest)) + ->not->toThrow(\Saloon\RateLimitPlugin\Exceptions\RateLimitReachedException::class); +}); + +test('only real requests count toward the limit when mixed with cache hits', function () { + $store = new MemoryStore; + + // 1 real request via mock client + $connector = new TestConnector($store, [Limit::allow(3)->everyMinute()]); + $connector->withMockClient(new MockClient([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ])); + $connector->send(new UserRequest); // hits = 1 + + // 5 cache hits via same store — should not add to the count + withCacheMiddleware($connector); + for ($i = 0; $i < 5; $i++) { + $connector->send(new UserRequest); + } + + // Only 1 real hit, limit of 3 not reached + expect($connector->hasReachedRateLimit())->toBeFalse(); +});