From 7c3b2ca6879a0accf2c7e69745ba9825934330d4 Mon Sep 17 00:00:00 2001 From: Evan Burrell Date: Thu, 19 Mar 2026 09:17:31 +0000 Subject: [PATCH 1/2] Fix exceeded() to always set expiry timestamp by falling back to configured interval When `exceeded()` is called without a `releaseInSeconds` value (e.g. when a 429 response has no Retry-After header), the expiry timestamp was not set explicitly. This caused it to be lazily calculated from "now" on each call to `getExpiryTimestamp()`, which can drift and lead to inconsistent state. Now `exceeded()` falls back to the limit's configured `releaseInSeconds` when no explicit value is provided, ensuring the expiry timestamp is always set deterministically at the moment the limit is exceeded. --- src/Limit.php | 6 ++++-- tests/Unit/LimitTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Limit.php b/src/Limit.php index 3d4bfe9..7d82c85 100644 --- a/src/Limit.php +++ b/src/Limit.php @@ -130,8 +130,10 @@ public function exceeded(?int $releaseInSeconds = null): void $this->hits = $this->allow; - if (isset($releaseInSeconds)) { - $interval = DateInterval::createFromDateString($releaseInSeconds . ' seconds'); + $seconds = $releaseInSeconds ?? $this->releaseInSeconds; + + if ($seconds > 0) { + $interval = DateInterval::createFromDateString($seconds . ' seconds'); if ($interval === false) { return; diff --git a/tests/Unit/LimitTest.php b/tests/Unit/LimitTest.php index e77bd59..b1a4c48 100644 --- a/tests/Unit/LimitTest.php +++ b/tests/Unit/LimitTest.php @@ -100,3 +100,31 @@ expect($limit->getReleaseInSeconds())->toEqual($seconds); }); + +test('exceeded without releaseInSeconds falls back to the configured interval', function () { + $limit = Limit::allow(10)->everySeconds(120); + + $limit->exceeded(); + + expect($limit->wasManuallyExceeded())->toBeTrue() + ->and($limit->getHits())->toBe(10) + ->and($limit->getRemainingSeconds())->toBe(120); +}); + +test('exceeded with explicit releaseInSeconds uses the provided value', function () { + $limit = Limit::allow(10)->everySeconds(120); + + $limit->exceeded(releaseInSeconds: 300); + + expect($limit->wasManuallyExceeded())->toBeTrue() + ->and($limit->getRemainingSeconds())->toBe(300); +}); + +test('custom limiter exceeded without releaseInSeconds falls back to default 60 seconds', function () { + $limit = Limit::custom(function () {}); + + $limit->exceeded(); + + expect($limit->wasManuallyExceeded())->toBeTrue() + ->and($limit->getRemainingSeconds())->toBe(60); +}); From 8e0501addc5f0728a15f46084e17c4cdbce90362 Mon Sep 17 00:00:00 2001 From: Evan Burrell Date: Wed, 25 Mar 2026 10:03:12 +0000 Subject: [PATCH 2/2] Fix flaky timestamp assertions with custom Pest expectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `toLookLike` custom expectation that tolerates ±1 second drift on timestamps, preventing flaky failures when a second boundary is crossed between capturing the expected time and executing the request. --- tests/Feature/HasRateLimitsTest.php | 34 ++++++++++++++--------------- tests/Pest.php | 16 ++++++++++++++ tests/Unit/LimitTest.php | 3 ++- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/tests/Feature/HasRateLimitsTest.php b/tests/Feature/HasRateLimitsTest.php index 97f3081..3a6c28f 100644 --- a/tests/Feature/HasRateLimitsTest.php +++ b/tests/Feature/HasRateLimitsTest.php @@ -51,12 +51,12 @@ expect($storeData)->toHaveKey('TestConnector:3_every_60'); expect($storeData)->toHaveKey('TestConnector:too_many_attempts_limit'); - expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toLookLike([ 'hits' => 1, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toLookLike([ 'hits' => 0, 'allow' => 1, 'timestamp' => $currentTimestampPlusSixty, @@ -69,12 +69,12 @@ $storeData = $store->getStore(); - expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toLookLike([ 'hits' => 2, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toLookLike([ 'hits' => 0, 'allow' => 1, 'timestamp' => $currentTimestampPlusSixty, @@ -87,12 +87,12 @@ $storeData = $store->getStore(); - expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toLookLike([ 'hits' => 3, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toLookLike([ 'hits' => 0, 'allow' => 1, 'timestamp' => $currentTimestampPlusSixty, @@ -145,7 +145,7 @@ expect($storeData)->toHaveKey('TestConnector:3_every_5'); expect($storeData)->toHaveKey('TestConnector:too_many_attempts_limit'); - expect(parseRawLimit($storeData['TestConnector:3_every_5']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:3_every_5']))->toLookLike([ 'hits' => 1, 'timestamp' => $currentTimestampPlusFive, ]); @@ -157,7 +157,7 @@ $storeData = $store->getStore(); - expect(parseRawLimit($storeData['TestConnector:3_every_5']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:3_every_5']))->toLookLike([ 'hits' => 2, 'timestamp' => $currentTimestampPlusFive, ]); @@ -169,7 +169,7 @@ $storeData = $store->getStore(); - expect(parseRawLimit($storeData['TestConnector:3_every_5']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:3_every_5']))->toLookLike([ 'hits' => 3, 'timestamp' => $currentTimestampPlusFive, ]); @@ -344,12 +344,12 @@ expect($storeData)->toHaveKey('LimitedRequest:60_every_60'); expect($storeData)->toHaveKey('LimitedRequest:too_many_attempts_limit'); - expect(parseRawLimit($storeData['LimitedRequest:60_every_60']))->toEqual([ + expect(parseRawLimit($storeData['LimitedRequest:60_every_60']))->toLookLike([ 'hits' => 1, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['LimitedRequest:too_many_attempts_limit']))->toEqual([ + expect(parseRawLimit($storeData['LimitedRequest:too_many_attempts_limit']))->toLookLike([ 'hits' => 0, 'allow' => 1, 'timestamp' => $currentTimestampPlusSixty, @@ -390,23 +390,23 @@ expect($storeData)->toHaveKey('TestConnector:3_every_60'); expect($storeData)->toHaveKey('TestConnector:too_many_attempts_limit'); - expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:3_every_60']))->toLookLike([ 'hits' => 1, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toEqual([ + expect(parseRawLimit($storeData['TestConnector:too_many_attempts_limit']))->toLookLike([ 'hits' => 0, 'allow' => 1, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['LimitedRequest:60_every_60']))->toEqual([ + expect(parseRawLimit($storeData['LimitedRequest:60_every_60']))->toLookLike([ 'hits' => 1, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['LimitedRequest:too_many_attempts_limit']))->toEqual([ + expect(parseRawLimit($storeData['LimitedRequest:too_many_attempts_limit']))->toLookLike([ 'hits' => 0, 'allow' => 1, 'timestamp' => $currentTimestampPlusSixty, @@ -443,12 +443,12 @@ expect($storeData)->toHaveKey('LimitedSoloRequest:60_every_60'); expect($storeData)->toHaveKey('LimitedSoloRequest:too_many_attempts_limit'); - expect(parseRawLimit($storeData['LimitedSoloRequest:60_every_60']))->toEqual([ + expect(parseRawLimit($storeData['LimitedSoloRequest:60_every_60']))->toLookLike([ 'hits' => 1, 'timestamp' => $currentTimestampPlusSixty, ]); - expect(parseRawLimit($storeData['LimitedSoloRequest:too_many_attempts_limit']))->toEqual([ + expect(parseRawLimit($storeData['LimitedSoloRequest:too_many_attempts_limit']))->toLookLike([ 'hits' => 0, 'allow' => 1, 'timestamp' => $currentTimestampPlusSixty, diff --git a/tests/Pest.php b/tests/Pest.php index 78c84f2..d24fc35 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -44,6 +44,22 @@ function parseRawLimit(?string $data): ?array return ! empty($data) ? json_decode($data, true) : null; } +expect()->extend('toLookLike', function (array $expected) { + $actual = $this->value; + + $expectedTimestamp = $expected['timestamp']; + unset($expected['timestamp']); + + $actualTimestamp = $actual['timestamp']; + unset($actual['timestamp']); + + expect($actual)->toEqual($expected); + expect($actualTimestamp)->toBeGreaterThanOrEqual($expectedTimestamp); + expect($actualTimestamp)->toBeLessThanOrEqual($expectedTimestamp + 1); + + return $this; +}); + /** * Reset the testing directory */ diff --git a/tests/Unit/LimitTest.php b/tests/Unit/LimitTest.php index b1a4c48..871c365 100644 --- a/tests/Unit/LimitTest.php +++ b/tests/Unit/LimitTest.php @@ -121,7 +121,8 @@ }); test('custom limiter exceeded without releaseInSeconds falls back to default 60 seconds', function () { - $limit = Limit::custom(function () {}); + $limit = Limit::custom(function () { + }); $limit->exceeded();