diff --git a/src/Bucket.php b/src/Bucket.php new file mode 100644 index 0000000..24bc671 --- /dev/null +++ b/src/Bucket.php @@ -0,0 +1,266 @@ +capacity = $capacity; + $instance->leakCount = 1; + $instance->leakSeconds = 1; + $instance->leakRate = 1; + $instance->releaseInSeconds = $capacity; + + return $instance; + } + + /** + * Set how many tokens leak + * + * @return $this + */ + public function leak(int $count): static + { + $this->leakCount = $count; + $this->recalculate(); + + return $this; + } + + /** + * Set leak time period in seconds + * + * @return $this + */ + public function every(int $seconds): static + { + $this->leakSeconds = $seconds; + $this->recalculate(); + + 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 + */ + 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; + } +} diff --git a/tests/Unit/BucketTest.php b/tests/Unit/BucketTest.php new file mode 100644 index 0000000..0fe09b6 --- /dev/null +++ b/tests/Unit/BucketTest.php @@ -0,0 +1,233 @@ +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('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) + ->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:'); +});