Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/Traits/HasRateLimits.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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();

Expand Down
125 changes: 125 additions & 0 deletions tests/Feature/CacheHitRateLimitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

declare(strict_types=1);

use Saloon\Enums\PipeOrder;
use Saloon\Http\PendingRequest;
use Saloon\RateLimitPlugin\Limit;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\FakeResponse;
use Saloon\Http\Faking\MockResponse;
use Saloon\RateLimitPlugin\Stores\MemoryStore;
use Saloon\RateLimitPlugin\Tests\Fixtures\Requests\UserRequest;
use Saloon\RateLimitPlugin\Tests\Fixtures\Connectors\TestConnector;

// Simulate what saloonphp/cache-plugin does: a request middleware with PipeOrder::FIRST
// returns a plain FakeResponse (not MockResponse). This mirrors CacheMiddleware exactly —
// it short-circuits the HTTP call, sets hasFakeResponse() on PendingRequest, and
// leaves isMocked() as false. DetermineMockResponse skips when hasFakeResponse() is already set.
function withCacheMiddleware(TestConnector $connector): void
{
$connector->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();
});