From 823ae41301806f3628b27db248d752ae7322fb15 Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Mon, 1 Dec 2025 23:54:58 +0000 Subject: [PATCH 1/3] wip --- src/Bucket.php | 262 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 src/Bucket.php diff --git a/src/Bucket.php b/src/Bucket.php new file mode 100644 index 0000000..c47af5b --- /dev/null +++ b/src/Bucket.php @@ -0,0 +1,262 @@ +capacity = $capacity; + $instance->leakCount = 1; + $instance->leakSeconds = 1; + $instance->leakRate = 1; + $instance->releaseInSeconds = $capacity; + + return $instance; + } + + /** + * Set how many tokens leak + */ + public function leak(int $count): static + { + $this->leakCount = $count; + $this->recalculate(); + return $this; + } + + /** + * Set leak time period in seconds + */ + public function every(int $seconds): static + { + $this->leakSeconds = $seconds; + $this->recalculate(); + return $this; + } + + /** + * Leak per second (convenience method) + */ + public function perSecond(): static + { + return $this->every(1); + } + + /** + * Leak per minute (convenience method) + */ + public function perMinute(): static + { + return $this->every(60); + } + + /** + * Recalculate leak rate based on current settings + */ + protected function recalculate(): void + { + $this->leakRate = $this->leakCount / $this->leakSeconds; + $this->releaseInSeconds = (int)ceil($this->capacity / $this->leakRate); + } + + public function getName(): string + { + if (isset($this->name)) { + return $this->prefix . ':' . $this->name; + } + + return sprintf( + '%s:bucket_cap%d_leak%d_per%ds', + $this->prefix, + $this->capacity, + $this->leakCount, + $this->leakSeconds + ); + } + + protected function processLeak(): static + { + $currentTimestamp = $this->getCurrentTimestamp(); + + if ($this->lastLeakTimestamp === null) { + $this->lastLeakTimestamp = $currentTimestamp; + return $this; + } + + $elapsedSeconds = $currentTimestamp - $this->lastLeakTimestamp; + + if ($elapsedSeconds > 0) { + $this->currentLevel = max(0, $this->currentLevel - ($elapsedSeconds * $this->leakRate)); + $this->lastLeakTimestamp = $currentTimestamp; + } + + return $this; + } + + public function hit(int $amount = 1): static + { + if ($this->wasManuallyExceeded()) { + return $this; + } + + $this->processLeak(); + $this->currentLevel += $amount; + $this->hits = (int)ceil($this->currentLevel); + + return $this; + } + + public function hasReachedLimit(?float $threshold = null): bool + { + $threshold ??= $this->threshold; + + if ($threshold < 0 || $threshold > 1) { + throw new InvalidArgumentException('Threshold must be between 0 and 1 (e.g., 0.85 for 85%).'); + } + + $this->processLeak(); + + return $this->currentLevel >= ($threshold * $this->capacity); + } + + public function getRemainingSeconds(): int + { + $this->processLeak(); + + if ($this->currentLevel < $this->capacity) { + return 0; + } + + return (int)ceil(1 / $this->leakRate); + } + + public function resetLimit(): static + { + $this->currentLevel = 0; + $this->hits = 0; + $this->lastLeakTimestamp = $this->getCurrentTimestamp(); + $this->exceeded = false; + $this->expiryTimestamp = null; + + return $this; + } + + public function update(RateLimitStore $store): static + { + $storeData = $store->get($this->getName()); + + if (empty($storeData)) { + return $this; + } + + $data = json_decode($storeData, true, 512, JSON_THROW_ON_ERROR); + + if (!isset($data['currentLevel'], $data['lastLeakTimestamp'])) { + throw new LimitException('Store data missing required fields: currentLevel, lastLeakTimestamp'); + } + + $this->currentLevel = (float)$data['currentLevel']; + $this->lastLeakTimestamp = (int)$data['lastLeakTimestamp']; + + // Restore leak rate if saved (for backwards compatibility) + if (isset($data['leakRate'])) { + $this->leakRate = (float)$data['leakRate']; + } + + $this->processLeak(); + $this->hits = (int)ceil($this->currentLevel); + + if (isset($data['exceeded'], $data['timestamp']) && $data['exceeded']) { + if ($this->getCurrentTimestamp() <= $data['timestamp']) { + $this->exceeded = true; + $this->expiryTimestamp = $data['timestamp']; + } + } + + return $this; + } + + public function save(RateLimitStore $store, int $resetHits = 1): static + { + $this->processLeak(); + + $data = [ + 'currentLevel' => $this->currentLevel, + 'lastLeakTimestamp' => $this->lastLeakTimestamp ?? $this->getCurrentTimestamp(), + 'capacity' => $this->capacity, + 'leakRate' => $this->leakRate, + ]; + + if ($this->exceeded && $this->expiryTimestamp !== null) { + $data['exceeded'] = true; + $data['timestamp'] = $this->expiryTimestamp; + } + + $ttl = $this->currentLevel > 0 + ? (int)ceil($this->currentLevel / $this->leakRate) + 60 + : 60; + + if (!$store->set($this->getName(), json_encode($data, JSON_THROW_ON_ERROR), $ttl)) { + throw new LimitException('Store failed to save limit data.'); + } + + return $this; + } + + public function exceeded(?int $releaseInSeconds = null): void + { + $this->exceeded = true; + $this->currentLevel = $this->capacity; + $this->hits = $this->capacity; + + if (isset($releaseInSeconds)) { + $interval = DateInterval::createFromDateString($releaseInSeconds . ' seconds'); + + if ($interval !== false) { + $this->expiryTimestamp = (new DateTimeImmutable)->add($interval)->getTimestamp(); + } + } + } + + public function getLeakRate(): float + { + return $this->leakRate; + } + + public function getCapacity(): int + { + return $this->capacity; + } + + public function getCurrentLevel(): float + { + $this->processLeak(); + return $this->currentLevel; + } + + public function getLastLeakTimestamp(): ?int + { + return $this->lastLeakTimestamp; + } +} + From 01de28949763289b81ec52f00b5caae077675035 Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Tue, 16 Dec 2025 02:05:03 +0000 Subject: [PATCH 2/3] add tests and remove redundant methods --- src/Bucket.php | 25 ++--- tests/Unit/BucketTest.php | 215 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 19 deletions(-) create mode 100644 tests/Unit/BucketTest.php diff --git a/src/Bucket.php b/src/Bucket.php index c47af5b..804314f 100644 --- a/src/Bucket.php +++ b/src/Bucket.php @@ -41,6 +41,7 @@ public function leak(int $count): static { $this->leakCount = $count; $this->recalculate(); + return $this; } @@ -51,23 +52,8 @@ public function every(int $seconds): static { $this->leakSeconds = $seconds; $this->recalculate(); - return $this; - } - - /** - * Leak per second (convenience method) - */ - public function perSecond(): static - { - return $this->every(1); - } - /** - * Leak per minute (convenience method) - */ - public function perMinute(): static - { - return $this->every(60); + return $this; } /** @@ -100,6 +86,7 @@ protected function processLeak(): static if ($this->lastLeakTimestamp === null) { $this->lastLeakTimestamp = $currentTimestamp; + return $this; } @@ -171,7 +158,7 @@ public function update(RateLimitStore $store): static $data = json_decode($storeData, true, 512, JSON_THROW_ON_ERROR); - if (!isset($data['currentLevel'], $data['lastLeakTimestamp'])) { + if (! isset($data['currentLevel'], $data['lastLeakTimestamp'])) { throw new LimitException('Store data missing required fields: currentLevel, lastLeakTimestamp'); } @@ -216,7 +203,7 @@ public function save(RateLimitStore $store, int $resetHits = 1): static ? (int)ceil($this->currentLevel / $this->leakRate) + 60 : 60; - if (!$store->set($this->getName(), json_encode($data, JSON_THROW_ON_ERROR), $ttl)) { + if (! $store->set($this->getName(), json_encode($data, JSON_THROW_ON_ERROR), $ttl)) { throw new LimitException('Store failed to save limit data.'); } @@ -251,6 +238,7 @@ public function getCapacity(): int public function getCurrentLevel(): float { $this->processLeak(); + return $this->currentLevel; } @@ -259,4 +247,3 @@ public function getLastLeakTimestamp(): ?int return $this->lastLeakTimestamp; } } - diff --git a/tests/Unit/BucketTest.php b/tests/Unit/BucketTest.php new file mode 100644 index 0000000..8d3e6c6 --- /dev/null +++ b/tests/Unit/BucketTest.php @@ -0,0 +1,215 @@ +getCapacity())->toEqual(100); + expect($bucket->getLeakRate())->toEqual(1.0); // Default: 1 token per second + expect($bucket->getCurrentLevel())->toEqual(0.0); +}); + +test('you can set the leak count', function () { + $bucket = Bucket::capacity(50)->leak(5); + + expect($bucket->getCapacity())->toEqual(50); + expect($bucket->getLeakRate())->toEqual(5.0); // 5 tokens per second +}); + +test('you can set the leak period', function () { + $bucket = Bucket::capacity(60)->leak(10)->every(2); + + expect($bucket->getCapacity())->toEqual(60); + expect($bucket->getLeakRate())->toEqual(5.0); // 10 tokens per 2 seconds = 5 per second +}); + +test('you can chain capacity, leak, and every methods', function () { + $bucket = Bucket::capacity(100) + ->leak(25) + ->every(5); + + expect($bucket->getCapacity())->toEqual(100); + expect($bucket->getLeakRate())->toEqual(5.0); // 25 tokens per 5 seconds = 5 per second +}); + +test('bucket calculates leak rate correctly', function (int $capacity, int $leakCount, int $leakSeconds, float $expectedRate) { + $bucket = Bucket::capacity($capacity) + ->leak($leakCount) + ->every($leakSeconds); + + expect($bucket->getLeakRate())->toEqual($expectedRate); +})->with([ + [100, 1, 1, 1.0], // 1 per second + [100, 10, 1, 10.0], // 10 per second + [100, 5, 2, 2.5], // 5 per 2 seconds = 2.5 per second + [100, 10, 5, 2.0], // 10 per 5 seconds = 2 per second + [100, 60, 60, 1.0], // 60 per minute = 1 per second +]); + +test('bucket name reflects its configuration', function () { + $bucket = Bucket::capacity(50) + ->leak(10) + ->every(2); + + $name = $bucket->getName(); + + expect($name)->toContain('bucket'); + expect($name)->toContain('cap50'); + expect($name)->toContain('leak10'); + expect($name)->toContain('per2s'); +}); + +test('bucket can be named with custom name', function () { + $bucket = Bucket::capacity(100)->name('my_custom_bucket'); + + expect($bucket->getName())->toContain('my_custom_bucket'); +}); + +test('bucket fills up when hit', function () { + $bucket = Bucket::capacity(10); + + $bucket->hit(); + expect($bucket->getCurrentLevel())->toEqual(1.0); + + $bucket->hit(3); + expect($bucket->getCurrentLevel())->toEqual(4.0); + + $bucket->hit(5); + expect($bucket->getCurrentLevel())->toEqual(9.0); +}); + +test('bucket reaches limit when full', function () { + $bucket = Bucket::capacity(5); + + expect($bucket->hasReachedLimit())->toBeFalse(); + + $bucket->hit(5); + expect($bucket->hasReachedLimit())->toBeTrue(); +}); + +test('bucket respects threshold', function () { + $bucket = Bucket::capacity(10); + + // At 80% threshold + $bucket->hit(7); + expect($bucket->hasReachedLimit(0.8))->toBeFalse(); + + $bucket->hit(1); + expect($bucket->hasReachedLimit(0.8))->toBeTrue(); // 8/10 = 80% +}); + +test('bucket can be reset', function () { + $bucket = Bucket::capacity(10); + + $bucket->hit(5); + expect($bucket->getCurrentLevel())->toEqual(5.0); + + $bucket->resetLimit(); + expect($bucket->getCurrentLevel())->toEqual(0.0); +}); + +test('bucket provides correct remaining seconds when full', function () { + $bucket = Bucket::capacity(10) + ->leak(5) // 5 tokens per second + ->every(1); + + $bucket->hit(10); // Fill bucket + + // Should take 1/5 = 0.2 seconds to leak 1 token, rounded up to 1 second + expect($bucket->getRemainingSeconds())->toEqual(1); +}); + +test('bucket returns zero remaining seconds when not full', function () { + $bucket = Bucket::capacity(10); + + $bucket->hit(5); // Half full + + expect($bucket->getRemainingSeconds())->toEqual(0); +}); + +test('bucket getters return correct values', function () { + $bucket = Bucket::capacity(20) + ->leak(4) + ->every(2); + + expect($bucket->getCapacity())->toEqual(20); + expect($bucket->getLeakRate())->toEqual(2.0); // 4 per 2 seconds + expect($bucket->getLastLeakTimestamp())->toBeNull(); + + $bucket->hit(); + expect($bucket->getCurrentLevel())->toEqual(1.0); + expect($bucket->getLastLeakTimestamp())->toBeInt(); +}); + +test('bucket handles sleep mode', function () { + $bucket = Bucket::capacity(10)->sleep(); + + expect($bucket->getShouldSleep())->toBeTrue(); +}); + +test('bucket can use threshold parameter in hasReachedLimit', function () { + $bucket = Bucket::capacity(10); + + $bucket->hit(8); // 80% full + + expect($bucket->hasReachedLimit())->toBeFalse(); // Default threshold 1.0 (100%) + expect($bucket->hasReachedLimit(0.75))->toBeTrue(); // 75% threshold + expect($bucket->hasReachedLimit(0.85))->toBeFalse(); // 85% threshold +}); + +test('bucket throws exception with invalid threshold', function () { + $bucket = Bucket::capacity(10); + + $bucket->hit(5); + + $bucket->hasReachedLimit(1.5); // > 1.0 +})->throws(InvalidArgumentException::class, 'Threshold must be between 0 and 1'); + +test('bucket leaks tokens over time', function () { + $bucket = Bucket::capacity(10) + ->leak(1) // 1 token per second + ->every(1); + + $bucket->hit(5); + expect($bucket->getCurrentLevel())->toEqual(5.0); + + // Simulate time passing by sleeping + sleep(2); + + // Should have leaked ~2 tokens (may vary slightly due to timing) + $level = $bucket->getCurrentLevel(); + expect($level)->toBeLessThan(5.0); + expect($level)->toBeGreaterThanOrEqual(2.0); +}); + +test('bucket level never goes below zero', function () { + $bucket = Bucket::capacity(10) + ->leak(5) + ->every(1); + + $bucket->hit(2); + expect($bucket->getCurrentLevel())->toEqual(2.0); + + sleep(1); // Leak 5 tokens but only 2 exist + + expect($bucket->getCurrentLevel())->toEqual(0.0); // Should not go negative +}); + +test('bucket can handle fractional leak rates', function () { + $bucket = Bucket::capacity(100) + ->leak(1) + ->every(3); // 1 token per 3 seconds = 0.333... per second + + expect($bucket->getLeakRate())->toBeGreaterThan(0.33); + expect($bucket->getLeakRate())->toBeLessThan(0.34); +}); + +test('bucket works with custom prefix', function () { + $bucket = Bucket::capacity(10) + ->setPrefix('custom'); + + expect($bucket->getName())->toStartWith('custom:'); +}); From ff1a41f4835bca6233e8a8cd78b624f66f0c79ab Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Tue, 16 Dec 2025 02:32:57 +0000 Subject: [PATCH 3/3] import hasintervals --- src/Bucket.php | 17 +++++++++++++++++ tests/Unit/BucketTest.php | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Bucket.php b/src/Bucket.php index 804314f..24bc671 100644 --- a/src/Bucket.php +++ b/src/Bucket.php @@ -7,11 +7,14 @@ use DateInterval; use DateTimeImmutable; use InvalidArgumentException; +use Saloon\RateLimitPlugin\Traits\HasIntervals; use Saloon\RateLimitPlugin\Contracts\RateLimitStore; use Saloon\RateLimitPlugin\Exceptions\LimitException; class Bucket extends Limit { + use HasIntervals; + protected float $leakRate; protected int $capacity; protected float $currentLevel = 0; @@ -36,6 +39,8 @@ public static function capacity(int $capacity): static /** * Set how many tokens leak + * + * @return $this */ public function leak(int $count): static { @@ -47,6 +52,8 @@ public function leak(int $count): static /** * Set leak time period in seconds + * + * @return $this */ public function every(int $seconds): static { @@ -56,6 +63,16 @@ public function every(int $seconds): static return $this; } + /** + * Override HasIntervals trait method to work with bucket leak logic + * + * @return $this + */ + public function everySeconds(int $seconds, ?string $timeToLiveKey = null): static + { + return $this->every($seconds); + } + /** * Recalculate leak rate based on current settings */ diff --git a/tests/Unit/BucketTest.php b/tests/Unit/BucketTest.php index 8d3e6c6..0fe09b6 100644 --- a/tests/Unit/BucketTest.php +++ b/tests/Unit/BucketTest.php @@ -35,6 +35,24 @@ expect($bucket->getLeakRate())->toEqual(5.0); // 25 tokens per 5 seconds = 5 per second }); +test('you can use everySeconds as an alias for every', function () { + $bucket = Bucket::capacity(60) + ->leak(10) + ->everySeconds(3); + + // 10 per 3 seconds = 3.333... per second + expect($bucket->getLeakRate())->toBeGreaterThan(3.33); + expect($bucket->getLeakRate())->toBeLessThan(3.34); +}); + +test('everySeconds ignores the timeToLiveKey parameter for bucket', function () { + $bucket = Bucket::capacity(50) + ->leak(5) + ->everySeconds(2, 'ignored_key'); + + expect($bucket->getLeakRate())->toEqual(2.5); // 5 per 2 seconds +}); + test('bucket calculates leak rate correctly', function (int $capacity, int $leakCount, int $leakSeconds, float $expectedRate) { $bucket = Bucket::capacity($capacity) ->leak($leakCount)