diff --git a/src/Helpers/LimitHelper.php b/src/Helpers/LimitHelper.php index cb509c2..d50e1d6 100644 --- a/src/Helpers/LimitHelper.php +++ b/src/Helpers/LimitHelper.php @@ -4,7 +4,6 @@ namespace Saloon\RateLimitPlugin\Helpers; -use Closure; use Saloon\RateLimitPlugin\Limit; use Saloon\RateLimitPlugin\Exceptions\LimitException; @@ -17,7 +16,7 @@ class LimitHelper * @return array * @throws LimitException */ - public static function configureLimits(array $limits, ?string $prefix, ?Closure $tooManyAttemptsHandler = null): array + public static function configureLimits(array $limits, ?string $prefix, ?Limit $tooManyAttemptsLimit = null): array { // Firstly, we will clean up the limits array to only ensure the `Limit` classes // are being processed. @@ -33,8 +32,8 @@ public static function configureLimits(array $limits, ?string $prefix, ?Closure // Next we will append our "too many attempts" limit which will be used when // the response actually hits a 429 status. - if (isset($tooManyAttemptsHandler)) { - $limits[] = Limit::custom($tooManyAttemptsHandler)->name('too_many_attempts_limit'); + if (isset($tooManyAttemptsLimit)) { + $limits[] = $tooManyAttemptsLimit->name('too_many_attempts_limit'); } // Next we will set the prefix on each of the limits. diff --git a/src/Traits/HasRateLimits.php b/src/Traits/HasRateLimits.php index 84c94a5..b19279e 100644 --- a/src/Traits/HasRateLimits.php +++ b/src/Traits/HasRateLimits.php @@ -54,7 +54,7 @@ public function bootHasRateLimits(PendingRequest $pendingRequest): void } }); - $pendingRequest->middleware()->onResponse(function (Response $response): void { + $pendingRequest->middleware()->onResponse(function (Response $response): Response { $limitThatWasExceeded = null; $store = $this->rateLimitStore(); @@ -92,8 +92,18 @@ public function bootHasRateLimits(PendingRequest $pendingRequest): void // place. We should make sure to throw the exception here. if (isset($limitThatWasExceeded)) { - $this->throwLimitException($limitThatWasExceeded); + if (! $limit->getShouldSleep()) { + $this->throwLimitException($limitThatWasExceeded); + } + + // When the limit has been instructed to sleep() we will make the request + // again, which will trigger the request middleware to sleep, and then + // hopefully get a successful response afterward. + + return $this->send($response->getRequest()); } + + return $response; }, order: PipeOrder::FIRST); } @@ -105,6 +115,16 @@ protected function getLimiterPrefix(): ?string return (new ReflectionClass($this))->getShortName(); } + /** + * Define the "Too Many Attempts" (429) limiter + * + * This limiter will automatically attempt to detect 429 requests. + */ + protected function getTooManyAttemptsLimiter(): ?Limit + { + return Limit::custom($this->handleTooManyAttempts(...)); + } + /** * Handle too many attempts (429) statuses */ @@ -209,9 +229,9 @@ public function rateLimitStore(): RateLimitStore */ public function getLimits(): array { - $tooManyAttemptsHandler = $this->detectTooManyAttempts === true ? $this->handleTooManyAttempts(...) : null; + $tooManyAttemptsLimit = $this->detectTooManyAttempts === true ? $this->getTooManyAttemptsLimiter() : null; - return LimitHelper::configureLimits($this->resolveLimits(), $this->getLimiterPrefix(), $tooManyAttemptsHandler); + return LimitHelper::configureLimits($this->resolveLimits(), $this->getLimiterPrefix(), $tooManyAttemptsLimit); } /** diff --git a/tests/Feature/HasRateLimitsTest.php b/tests/Feature/HasRateLimitsTest.php index a071178..97f3081 100644 --- a/tests/Feature/HasRateLimitsTest.php +++ b/tests/Feature/HasRateLimitsTest.php @@ -16,6 +16,7 @@ use Saloon\Exceptions\Request\Statuses\InternalServerErrorException; use Saloon\RateLimitPlugin\Tests\Fixtures\Requests\LimitedSoloRequest; use Saloon\RateLimitPlugin\Tests\Fixtures\Connectors\CustomPrefixConnector; +use Saloon\RateLimitPlugin\Tests\Fixtures\Connectors\SleepTooManyRequestsConnector; use Saloon\RateLimitPlugin\Tests\Fixtures\Connectors\CustomTooManyRequestsConnector; use Saloon\RateLimitPlugin\Tests\Fixtures\Connectors\DisabledTooManyRequestsConnector; @@ -173,11 +174,11 @@ 'timestamp' => $currentTimestampPlusFive, ]); - // Now when we make this request, it should pause the application for 10 seconds + // Now when we make this request, it should pause the application for 5 seconds $connector->send(new UserRequest); - expect(time())->toEqual($currentTimestampPlusFive); + expect(time())->toBeGreaterThanOrEqual($currentTimestampPlusFive); }); test('you can create a limiter that listens for 429 and will automatically back off for the Retry-After duration', function () { @@ -293,6 +294,27 @@ expect($store->getStore())->toBeEmpty(); }); +test('you can customise the 429 error detection to sleep instead', function () { + $store = new MemoryStore; + $connector = new SleepTooManyRequestsConnector($store, []); + + $connector->withMockClient(new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + MockResponse::make(['status' => 'Too Many Requests'], 429), + MockResponse::make(['name' => 'Jon'], 200), + ])); + + $startTime = time(); + + $connector->send(new UserRequest); + + $response = $connector->send(new UserRequest); + + expect($response->json())->toBe(['name' => 'Jon']); + + expect(time())->toBeGreaterThanOrEqual($startTime + 5); +}); + test('the rate limiter can be used on a request', function () { $store = new MemoryStore; diff --git a/tests/Fixtures/Connectors/SleepTooManyRequestsConnector.php b/tests/Fixtures/Connectors/SleepTooManyRequestsConnector.php new file mode 100644 index 0000000..17f4c31 --- /dev/null +++ b/tests/Fixtures/Connectors/SleepTooManyRequestsConnector.php @@ -0,0 +1,28 @@ +handleTooManyAttempts(...))->sleep(); + } + + /** + * Handle too many attempts (429) statuses + */ + protected function handleTooManyAttempts(Response $response, Limit $limit): void + { + if ($response->status() !== 429) { + return; + } + + $limit->exceeded(releaseInSeconds: 5); + } +}