From 252a10dfd1919003f75681ee31357e23cc09e47b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:43:01 +0530 Subject: [PATCH 1/7] feat(token-bucket): add agnostic adapter and Redis-family base Introduce the storage-agnostic TokenBucket adapter (mirrors SlidingWindow: count()/remaining()/limit()/time()) and RedisBase, which owns the atomic refill-and-consume Lua script, a read-only refill estimate and the eval()/delete() seams. Bucket state is a single Redis hash; ttl is derived from capacity/refillRate. --- src/Abuse/Adapters/TokenBucket.php | 107 +++++++++ src/Abuse/Adapters/TokenBucket/RedisBase.php | 239 +++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 src/Abuse/Adapters/TokenBucket.php create mode 100644 src/Abuse/Adapters/TokenBucket/RedisBase.php diff --git a/src/Abuse/Adapters/TokenBucket.php b/src/Abuse/Adapters/TokenBucket.php new file mode 100644 index 0000000..899ce6e --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket.php @@ -0,0 +1,107 @@ +tokens - ($this->count($this->parseKey(), $this->timestamp) + 1); + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the bucket capacity + * + * @return int + */ + public function limit(): int + { + return $this->tokens; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Clear the bucket for the current key so it starts full again. + * + * @return void + * + * @throws \Exception + */ + abstract public function reset(): void; +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisBase.php b/src/Abuse/Adapters/TokenBucket/RedisBase.php new file mode 100644 index 0000000..fb633b8 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisBase.php @@ -0,0 +1,239 @@ += 1 then + tokens = tokens - 1 + allowed = 1 + end + + redis.call('HSET', key, 'tokens', tostring(tokens), 'last_refill', tostring(now)) + redis.call('EXPIRE', key, math.ceil(max_tokens / refill_rate) + 1) + + return { allowed, tostring(tokens) } + LUA; + + /** + * Read-only token estimate: refills the bucket for the elapsed time without + * consuming anything or writing back. Used by remaining(). + * + * KEYS[1] bucket hash key. + * ARGV[1] max_tokens, ARGV[2] refill_rate, ARGV[3] now. + * + * Returns the available token balance as a string. + */ + protected const string TOKENS_SCRIPT = <<<'LUA' + local key = KEYS[1] + local max_tokens = tonumber(ARGV[1]) + local refill_rate = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + + local data = redis.call('HMGET', key, 'tokens', 'last_refill') + local tokens = tonumber(data[1]) or max_tokens + local last_refill = tonumber(data[2]) or now + + local elapsed = now - last_refill + if elapsed < 0 then elapsed = 0 end + tokens = math.min(max_tokens, tokens + elapsed * refill_rate) + + return tostring(tokens) + LUA; + + /** + * Tokens refilled per second. + * + * @var float + */ + protected float $refillRate; + + /** + * Run a Lua script against the storage backend. + * + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed the raw script result + */ + abstract protected function eval(string $script, array $keys, array $argv): mixed; + + /** + * Delete the given keys. + * + * @param string ...$keys + * @return void + */ + abstract protected function delete(string ...$keys): void; + + /** + * Validate and store the bucket configuration. + * + * @param float $refillRate + * @return void + */ + protected function initBucket(float $refillRate): void + { + if ($refillRate <= 0) { + throw new \InvalidArgumentException('refillRate must be greater than 0'); + } + + $this->refillRate = $refillRate; + $this->timestamp = \time(); + } + + /** + * Build the bucket hash key for a given abuse key. + * + * @param string $key + * @return string + */ + protected function bucketKey(string $key): string + { + return self::NAMESPACE . '__' . $key; + } + + /** + * Check + * + * @return bool + * + * @throws \Throwable + */ + public function check(): bool + { + if ($this->tokens === 0) { + return false; + } + + $key = $this->parseKey(); + $this->timestamp = \time(); + + /** @var array{0:int,1:string} $result */ + $result = $this->eval( + self::LIMIT_CHECK_SCRIPT, + [ + $this->bucketKey($key), // KEYS[1] bucket hash + ], + [ + $this->tokens, // ARGV[1] max_tokens + $this->refillRate, // ARGV[2] refill_rate + \microtime(true), // ARGV[3] now (fractional seconds) + ], + ); + + // $available is the token balance left after consuming; store the + // consumed count so a following remaining() stays consistent with it. + [$allowed, $available] = $result; + $balance = \is_numeric($available) ? (float) $available : 0.0; + $this->count = (int) \floor($this->tokens - $balance); + + return (int) $allowed === 0; + } + + /** + * Count + * + * Read-only estimate of the tokens already consumed from the bucket + * (capacity minus the tokens available after refilling). Used by remaining(). + * Reuses the value recorded by the most recent check()/reset() this request; + * otherwise reads a fresh estimate from storage. + * + * @param string $key + * @param int $timestamp + * @return int + */ + protected function count(string $key, int $timestamp): int + { + if ($this->tokens === 0) { + return 0; + } + + $this->timestamp = \time(); + + if ($this->count !== null) { + return $this->count; + } + + $raw = $this->eval( + self::TOKENS_SCRIPT, + [ + $this->bucketKey($key), + ], + [ + $this->tokens, + $this->refillRate, + \microtime(true), + ], + ); + + $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; + $this->count = (int) \floor($this->tokens - $balance); + + return $this->count; + } + + /** + * Reset + * + * Drop the bucket state so the next request sees a full bucket. + * + * @return void + */ + public function reset(): void + { + $this->delete($this->bucketKey($this->parseKey())); + + $this->count = 0; + } + + /** + * No need for manual cleanup - Redis TTL handles this automatically + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} From 1830b617832666b77793b92f86c3460080e4feeb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:43:10 +0530 Subject: [PATCH 2/7] feat(token-bucket): add Redis, RedisCluster and RedisPool adapters Concrete adapters implementing the eval()/delete() seams plus getLogs() (reads bucket hashes via hGetAll). Constructor takes (key, tokens, refillRate). --- src/Abuse/Adapters/TokenBucket/Redis.php | 81 +++++++++++ .../Adapters/TokenBucket/RedisCluster.php | 81 +++++++++++ src/Abuse/Adapters/TokenBucket/RedisPool.php | 131 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 src/Abuse/Adapters/TokenBucket/Redis.php create mode 100644 src/Abuse/Adapters/TokenBucket/RedisCluster.php create mode 100644 src/Abuse/Adapters/TokenBucket/RedisPool.php diff --git a/src/Abuse/Adapters/TokenBucket/Redis.php b/src/Abuse/Adapters/TokenBucket/Redis.php new file mode 100644 index 0000000..58e2402 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/Redis.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $this->redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisCluster.php b/src/Abuse/Adapters/TokenBucket/RedisCluster.php new file mode 100644 index 0000000..ced320c --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisCluster.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisClusterException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisClusterException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs with cursor-based pagination across masters + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0 && count($matches) < $offset + $limit); + } + + sort($matches); + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisPool.php b/src/Abuse/Adapters/TokenBucket/RedisPool.php new file mode 100644 index 0000000..34cfec9 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisPool.php @@ -0,0 +1,131 @@ +|UtopiaPool<\RedisCluster> $pool Pool yielding a Redis or RedisCluster connection + */ + public function __construct( + protected string $key, + protected int $tokens, + float $refillRate, + protected UtopiaPool $pool + ) { + $this->initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->eval($script, [...$keys, ...$argv], \count($keys))); + } + + /** + * @param string ...$keys + * @return void + */ + protected function delete(string ...$keys): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { + $redis->del(...$keys); + }); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + }); + + return $result; + } + + /** + * @param \RedisCluster $redis + * @param int $offset + * @param int $limit + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + } +} From 84f4424310081e034ffafecb6c52bbecaa7b4945 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:43:10 +0530 Subject: [PATCH 3/7] test(token-bucket): add Redis, RedisCluster and RedisPool test cases Shared Base suite covering burst/capacity, remaining, refill over time, reset, refill-rate guard and unlimited (tokens 0). --- tests/Abuse/TokenBucket/Base.php | 160 +++++++++++++++++++ tests/Abuse/TokenBucket/RedisClusterTest.php | 45 ++++++ tests/Abuse/TokenBucket/RedisPoolTest.php | 53 ++++++ tests/Abuse/TokenBucket/RedisTest.php | 43 +++++ 4 files changed, 301 insertions(+) create mode 100644 tests/Abuse/TokenBucket/Base.php create mode 100644 tests/Abuse/TokenBucket/RedisClusterTest.php create mode 100644 tests/Abuse/TokenBucket/RedisPoolTest.php create mode 100644 tests/Abuse/TokenBucket/RedisTest.php diff --git a/tests/Abuse/TokenBucket/Base.php b/tests/Abuse/TokenBucket/Base.php new file mode 100644 index 0000000..e9ce08b --- /dev/null +++ b/tests/Abuse/TokenBucket/Base.php @@ -0,0 +1,160 @@ +getAdapter('tb-static-key', 2, 0.001); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with a capacity of 2 tokens + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('tb-dynamic-key-{{ip}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('tb-two-params-{{ip}}-{{email}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a full bucket allows a burst up to its capacity + */ + public function testBurst(): void + { + $adapter = $this->getAdapter('tb-burst-{{ip}}', 10, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.11'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that remaining reports the tokens still available + */ + public function testRemaining(): void + { + $adapter = $this->getAdapter('tb-remaining-{{ip}}', 3, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.12'); + $abuse = new Abuse($adapter); + + $this->assertSame(2, $adapter->remaining()); // full bucket: limit - (0 + 1) + $this->assertSame(false, $abuse->check()); // 1 consumed + $this->assertSame(1, $adapter->remaining()); + $this->assertSame(false, $abuse->check()); // 2 consumed + $this->assertSame(0, $adapter->remaining()); + } + + /** + * Test that tokens refill over time + */ + public function testRefill(): void + { + // 1 token/sec, capacity 1: consume it, then a refill lets one more through + $adapter = $this->getAdapter('tb-refill-{{ip}}', 1, 1.0); + $adapter->setParam('{{ip}}', '0.0.0.13'); + $abuse = new Abuse($adapter); + + $this->assertSame(false, $abuse->check()); // consume the only token + $this->assertSame(true, $abuse->check()); // empty, throttled + + sleep(2); // refill ~2 tokens (capped at capacity 1) + + $this->assertSame(false, $abuse->check()); // refilled, allowed again + } + + /** + * Verify that time() returns the current time as an int + */ + public function testTimeFormat(): void + { + $adapter = $this->getAdapter('tb-time', 1, 1.0); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality refills the bucket + */ + public function testReset(): void + { + $adapter = $this->getAdapter('tb-reset-test-{{ip}}', 5, 0.001); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th limited + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Reset refills the bucket + $abuse->reset(); + + // 5 more OK, then limited again + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a non-positive refill rate is rejected + */ + public function testRefillRateGuard(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->getAdapter('tb-guard', 1, 0.0); + } + + /** + * Test that limit 0 means unlimited + */ + public function testUnlimited(): void + { + $adapter = $this->getAdapter('tb-unlimited', 0, 1.0); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 20; $i++) { + $this->assertSame(false, $abuse->check()); + } + } +} diff --git a/tests/Abuse/TokenBucket/RedisClusterTest.php b/tests/Abuse/TokenBucket/RedisClusterTest.php new file mode 100644 index 0000000..7b9a0c5 --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisClusterTest.php @@ -0,0 +1,45 @@ +close(); + } + } +} diff --git a/tests/Abuse/TokenBucket/RedisPoolTest.php b/tests/Abuse/TokenBucket/RedisPoolTest.php new file mode 100644 index 0000000..750666f --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisPoolTest.php @@ -0,0 +1,53 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-tb-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect('redis', 6379); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('tb-pool-' . $key, $tokens, $refillRate, $pool); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } +} diff --git a/tests/Abuse/TokenBucket/RedisTest.php b/tests/Abuse/TokenBucket/RedisTest.php new file mode 100644 index 0000000..bb1b94a --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisTest.php @@ -0,0 +1,43 @@ +connect('redis', 6379); + + return $redis; + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + return new AdapterRedis($key, $tokens, $refillRate, self::$redis); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +} From 70e1e4cdb5b9bf953e15b3d052e7c0a7444b2703 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:48:15 +0530 Subject: [PATCH 4/7] fix(token-bucket): floor available balance before deriving consumed count Fractional refill made floor(capacity - balance) undercount consumed tokens by one (e.g. balance 1.00001 -> 1 consumed instead of 2). Floor the available balance first, then subtract from capacity so remaining() stays exact. --- src/Abuse/Adapters/TokenBucket/RedisBase.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Abuse/Adapters/TokenBucket/RedisBase.php b/src/Abuse/Adapters/TokenBucket/RedisBase.php index fb633b8..f806b7b 100644 --- a/src/Abuse/Adapters/TokenBucket/RedisBase.php +++ b/src/Abuse/Adapters/TokenBucket/RedisBase.php @@ -165,7 +165,7 @@ public function check(): bool // consumed count so a following remaining() stays consistent with it. [$allowed, $available] = $result; $balance = \is_numeric($available) ? (float) $available : 0.0; - $this->count = (int) \floor($this->tokens - $balance); + $this->count = $this->tokens - (int) \floor($balance); return (int) $allowed === 0; } @@ -207,7 +207,7 @@ protected function count(string $key, int $timestamp): int ); $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; - $this->count = (int) \floor($this->tokens - $balance); + $this->count = $this->tokens - (int) \floor($balance); return $this->count; } From 6c152d3e3f4a98b80fed6e5e56459b351674e104 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 12:12:19 +0530 Subject: [PATCH 5/7] fix(token-bucket): always read a fresh refill estimate in count() The bucket refills continuously, so caching the consumed count made remaining() report a stale quota once time elapsed after a check(). Drop the cache and compute a fresh estimate on every count(); check() now only needs the allow decision. --- src/Abuse/Adapters/TokenBucket.php | 5 ----- src/Abuse/Adapters/TokenBucket/RedisBase.php | 19 ++++--------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/Abuse/Adapters/TokenBucket.php b/src/Abuse/Adapters/TokenBucket.php index 899ce6e..19d50d3 100644 --- a/src/Abuse/Adapters/TokenBucket.php +++ b/src/Abuse/Adapters/TokenBucket.php @@ -15,11 +15,6 @@ abstract class TokenBucket extends Adapter */ protected int $tokens = 0; - /** - * @var int|null - */ - protected ?int $count = null; - /** * @var int */ diff --git a/src/Abuse/Adapters/TokenBucket/RedisBase.php b/src/Abuse/Adapters/TokenBucket/RedisBase.php index f806b7b..6fa8ec9 100644 --- a/src/Abuse/Adapters/TokenBucket/RedisBase.php +++ b/src/Abuse/Adapters/TokenBucket/RedisBase.php @@ -161,11 +161,7 @@ public function check(): bool ], ); - // $available is the token balance left after consuming; store the - // consumed count so a following remaining() stays consistent with it. - [$allowed, $available] = $result; - $balance = \is_numeric($available) ? (float) $available : 0.0; - $this->count = $this->tokens - (int) \floor($balance); + [$allowed] = $result; return (int) $allowed === 0; } @@ -175,8 +171,8 @@ public function check(): bool * * Read-only estimate of the tokens already consumed from the bucket * (capacity minus the tokens available after refilling). Used by remaining(). - * Reuses the value recorded by the most recent check()/reset() this request; - * otherwise reads a fresh estimate from storage. + * The bucket refills continuously, so this always reads a fresh estimate + * rather than reusing a cached value that would go stale as tokens refill. * * @param string $key * @param int $timestamp @@ -190,10 +186,6 @@ protected function count(string $key, int $timestamp): int $this->timestamp = \time(); - if ($this->count !== null) { - return $this->count; - } - $raw = $this->eval( self::TOKENS_SCRIPT, [ @@ -207,9 +199,8 @@ protected function count(string $key, int $timestamp): int ); $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; - $this->count = $this->tokens - (int) \floor($balance); - return $this->count; + return $this->tokens - (int) \floor($balance); } /** @@ -222,8 +213,6 @@ protected function count(string $key, int $timestamp): int public function reset(): void { $this->delete($this->bucketKey($this->parseKey())); - - $this->count = 0; } /** From 6a011fe110fca922ac5cfdc7d91aff68994f6fda Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 12:12:19 +0530 Subject: [PATCH 6/7] fix(token-bucket): scan every cluster master fully before paginating logs Stopping each master's unordered scan at offset+limit and then sorting/slicing the partial candidate set returned a biased page and could omit keys in range. Scan all masters completely, then sort and slice. --- src/Abuse/Adapters/TokenBucket/RedisCluster.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Abuse/Adapters/TokenBucket/RedisCluster.php b/src/Abuse/Adapters/TokenBucket/RedisCluster.php index ced320c..beb86ec 100644 --- a/src/Abuse/Adapters/TokenBucket/RedisCluster.php +++ b/src/Abuse/Adapters/TokenBucket/RedisCluster.php @@ -61,7 +61,7 @@ public function getLogs(?int $offset = 0, ?int $limit = 25): array if ($keys !== false) { $matches = array_merge($matches, $keys); } - } while ($cursor > 0 && count($matches) < $offset + $limit); + } while ($cursor > 0); } sort($matches); From 4afb6a26477b64a61c32037ead4acdb45d2b8966 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 15:36:21 +0530 Subject: [PATCH 7/7] feat(token-bucket): add None adapter --- src/Abuse/Adapters/TokenBucket/None.php | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/Abuse/Adapters/TokenBucket/None.php diff --git a/src/Abuse/Adapters/TokenBucket/None.php b/src/Abuse/Adapters/TokenBucket/None.php new file mode 100644 index 0000000..cdec354 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/None.php @@ -0,0 +1,57 @@ +key = $key; + $this->tokens = $tokens; + $this->timestamp = \time(); + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + public function check(): bool + { + return false; + } + + public function reset(): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +}