diff --git a/CHANGELOG.md b/CHANGELOG.md index c000105..6dbd896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 3.0.0 under development +- Chg #44: Use PSR-20 `ClockInterface` instead of `TimerInterface` (@samdark) - New #43: Add APCu counters storage (@jiaweipan) - Enh #41: Adapt package to concurrent requests, for this `StorageInterface` method `save()` split to `saveIfNotExists()` and `saveCompareAndSwap()` (@jiaweipan) diff --git a/README.md b/README.md index 8230963..a2487e0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![static analysis](https://github.com/yiisoft/rate-limiter/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/rate-limiter/actions?query=workflow%3A%22static+analysis%22) [![type-coverage](https://shepherd.dev/github/yiisoft/rate-limiter/coverage.svg)](https://shepherd.dev/github/yiisoft/rate-limiter) -Rate limiter middleware helps to prevent abuse by limiting the number of requests that could be me made consequentially. +Rate limiter middleware helps to prevent abuse by limiting the number of requests that could be made consequentially. For example, you may want to limit the API usage of each user to be at most 100 API calls within a period of 10 minutes. If too many requests are received from a user within the stated period of the time, a response with status code 429 @@ -61,9 +61,9 @@ are limited and 5 is a period to apply limit to, in seconds. The `Counter` implements [generic cell rate limit algorithm (GCRA)](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm) that ensures that after reaching the limit further increments are distributed equally. -> Note: While it is sufficiently effective, it is preferred to use [Nginx](https://www.nginx.com/blog/rate-limiting-nginx/) +> Note: While it's sufficiently effective, it's preferred to use [Nginx](https://www.nginx.com/blog/rate-limiting-nginx/) > or another webserver capabilities for rate limiting. This package allows rate-limiting in the project with deployment -> environment you cannot control such as installable CMS. +> environment you can't control such as installable CMS. ### Implementing your own limiting policy @@ -84,7 +84,7 @@ Easiest way to customize a policy is to use `LimitCallback`: ```php $middleware = new LimitRequestsMiddleware($counter, $responseFactory, new LimitCallback(function (ServerRequestInterface $request): string { - // return user id from database if authentication id used i.e. limit guests and each authenticated user separately. + // return user id from a database if authentication id used i.e. limit guests and each authenticated user separately. })); ``` @@ -127,7 +127,7 @@ The code is statically analyzed with [Psalm](https://psalm.dev/). To run static ## License -The Yii Rate Limiter Middleware is free software. It is released under the terms of the BSD License. +The Yii Rate Limiter Middleware is free software. It's released under the terms of the BSD License. Please see [`LICENSE`](./LICENSE.md) for more information. Maintained by [Yii Software](https://www.yiiframework.com/). diff --git a/composer.json b/composer.json index 7343016..41a0f86 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/simple-cache": "^2.0|^3.0", - "yiisoft/http": "^1.2" + "yiisoft/http": "^1.2", + "psr/clock": "^1.0" }, "require-dev": { "ext-apcu": "*", diff --git a/src/Counter.php b/src/Counter.php index 7658010..4578849 100644 --- a/src/Counter.php +++ b/src/Counter.php @@ -5,9 +5,9 @@ namespace Yiisoft\Yii\RateLimiter; use InvalidArgumentException; +use Psr\Clock\ClockInterface; use Yiisoft\Yii\RateLimiter\Storage\StorageInterface; -use Yiisoft\Yii\RateLimiter\Time\MicrotimeTimer; -use Yiisoft\Yii\RateLimiter\Time\TimerInterface; +use Yiisoft\Yii\RateLimiter\Time\SystemClock; /** * Counter implements generic cell rate limit algorithm (GCRA) that ensures that after reaching the limit further @@ -28,19 +28,19 @@ final class Counter implements CounterInterface private int $periodInMilliseconds; /** - * @var float Maximum interval before next increment. - * In GCRA it is known as emission interval. + * @var float Maximum interval before the next increment. + * In GCRA it's known as an emission interval. */ private float $incrementIntervalInMilliseconds; - private TimerInterface $timer; + private ClockInterface $timer; /** * @param StorageInterface $storage Storage to use for counter values. - * @param int $limit Maximum number of increments that could be performed before increments are limited. + * @param int $limit A maximum number of increments that could be performed before increments are limited. * @param int $periodInSeconds Period to apply limit to. * @param int $storageTtlInSeconds Storage TTL. Should be higher than `$periodInSeconds`. * @param string $storagePrefix Storage prefix. - * @param TimerInterface|null $timer Timer instance to get current time from. + * @param ClockInterface|null $timer Timer instance to get current time from. * @param int $maxCasAttempts Maximum number of times to retry saveIfNotExists/saveCompareAndSwap operations before returning an error. */ public function __construct( @@ -49,7 +49,7 @@ public function __construct( int $periodInSeconds, private int $storageTtlInSeconds = self::DEFAULT_TTL, private string $storagePrefix = self::ID_PREFIX, - TimerInterface|null $timer = null, + ClockInterface|null $timer = null, private int $maxCasAttempts = self::DEFAULT_MAX_CAS_ATTEMPTS, ) { if ($limit < 1) { @@ -61,7 +61,7 @@ public function __construct( } $this->periodInMilliseconds = $periodInSeconds * self::MILLISECONDS_PER_SECOND; - $this->timer = $timer ?: new MicrotimeTimer(); + $this->timer = $timer ?: new SystemClock(); $this->incrementIntervalInMilliseconds = $this->periodInMilliseconds / $this->limit; } @@ -75,7 +75,7 @@ public function hit(string $id): CounterState do { // Last increment time. // In GCRA it's known as arrival time. - $lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds(); + $lastIncrementTimeInMilliseconds = round((float)$this->timer->now()->format('U.u') * 1000); $lastStoredTheoreticalNextIncrementTime = $this->getLastStoredTheoreticalNextIncrementTime($id); @@ -112,7 +112,7 @@ public function hit(string $id): CounterState /** * @return float Theoretical increment time that would be expected from equally spaced increments at exactly rate - * limit. In GCRA it is known as TAT, theoretical arrival time. + * limit. In GCRA it's known as TAT, theoretical arrival time. */ private function calculateTheoreticalNextIncrementTime( float $lastIncrementTimeInMilliseconds, diff --git a/src/Time/MicrotimeTimer.php b/src/Time/MicrotimeTimer.php deleted file mode 100644 index cbae9af..0000000 --- a/src/Time/MicrotimeTimer.php +++ /dev/null @@ -1,15 +0,0 @@ -getStorage(), 10, @@ -72,7 +72,7 @@ public function testIncrementMustBeUniformAfterLimitIsReached(): void for ($i = 0; $i < 5; $i++) { // Move timer forward for (period in milliseconds / limit) // i.e. once in period / limit remaining allowance should be increased by 1. - FrozenTimeTimer::setTimeMark($timer->nowInMilliseconds() + 100); + $timer->modify('+100 milliseconds'); $statistics = $counter->hit('key'); $this->assertEquals(1, $statistics->getRemaining()); } @@ -82,18 +82,19 @@ public function testCustomTtl(): void { $storage = $this->getStorage(); + $clock = new FrozenClock(); $counter = new Counter( $storage, 1, 1, 1, 'rate-limiter-', - new FrozenTimeTimer() + $clock ); $counter->hit('test'); - FrozenTimeTimer::setTimeMark((new MicrotimeTimer())->nowInMilliseconds() + 2); + $clock->modify('+2 milliseconds'); self::assertNull($storage->get('rate-limiter-test')); } diff --git a/tests/CounterTest.php b/tests/CounterTest.php index a30cc88..66af27b 100644 --- a/tests/CounterTest.php +++ b/tests/CounterTest.php @@ -9,7 +9,7 @@ use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage; use Yiisoft\Yii\RateLimiter\Storage\StorageInterface; use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FakeSimpleCacheStorage; -use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenTimeTimer; +use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenClock; final class CounterTest extends BaseCounterTest { @@ -20,11 +20,11 @@ protected function getStorage(): StorageInterface /** * Testing that in concurrent scenarios, when dirty reads occur, - * the current limiter cannot be as expected By 'SimpleCacheStorage'. + * the current limiter can't be as expected By 'SimpleCacheStorage'. */ public function testConcurrentHitsWithDirtyReading(): void { - $timer = new FrozenTimeTimer(); + $timer = new FrozenClock(); $storage = new FakeSimpleCacheStorage(new ArrayCache(), 5); $limitHits = 10; $counter = new Counter( diff --git a/tests/Fixtures/FakeApcuStorage.php b/tests/Fixtures/FakeApcuStorage.php index 73f9543..19f1d03 100644 --- a/tests/Fixtures/FakeApcuStorage.php +++ b/tests/Fixtures/FakeApcuStorage.php @@ -45,7 +45,7 @@ public function get(string $key): ?float if ($readValue === false) { return null; } - + $readValue = (float) ($readValue / $this->fixPrecisionRate); $this->dirtyReadValue = $readValue; $this->remainingDirtyReadCount = $this->dirtyReadCount; diff --git a/tests/Fixtures/FrozenClock.php b/tests/Fixtures/FrozenClock.php new file mode 100644 index 0000000..5c319e1 --- /dev/null +++ b/tests/Fixtures/FrozenClock.php @@ -0,0 +1,31 @@ +now = new DateTimeImmutable(); + } + + public function now(): DateTimeImmutable + { + return $this->now; + } + + public function modify(string $modifier): void + { + $this->now = $this->now->modify($modifier); + } +} diff --git a/tests/Fixtures/FrozenTimeTimer.php b/tests/Fixtures/FrozenTimeTimer.php deleted file mode 100644 index 3f31c33..0000000 --- a/tests/Fixtures/FrozenTimeTimer.php +++ /dev/null @@ -1,37 +0,0 @@ -getStorage(); - $value = (new FrozenTimeTimer())->nowInMilliseconds(); + $value = round((float)(new DateTimeImmutable())->format('U.u') * 1000); $storage->saveIfNotExists('exists_key', $value, self::DEFAULT_TTL); $result = $storage->saveIfNotExists('exists_key', $value, self::DEFAULT_TTL); @@ -47,7 +47,7 @@ public function testSaveCompareAndSwapWithNewKey(): void { $storage = $this->getStorage(); - $newValue = (new FrozenTimeTimer())->nowInMilliseconds(); + $newValue = round((float)(new DateTimeImmutable())->format('U.u') * 1000); $oldValue = (int) $storage->get('new_key'); $result = $storage->saveCompareAndSwap( @@ -60,11 +60,11 @@ public function testSaveCompareAndSwapWithNewKey(): void $this->assertFalse($result); } - public function testSaveCompareAndSwapWithExistsKeyButOldValueDiffrent(): void + public function testSaveCompareAndSwapWithExistsKeyButOldValueDifferent(): void { $storage = $this->getStorage(); - $oldValue = (new FrozenTimeTimer())->nowInMilliseconds(); + $oldValue = round((float)(new DateTimeImmutable())->format('U.u') * 1000); $storage->saveIfNotExists('exists_key', $oldValue, self::DEFAULT_TTL); $oldValue = $oldValue + 200; diff --git a/tests/Storage/StorageTest.php b/tests/Storage/StorageTest.php index 46737a5..1355d32 100644 --- a/tests/Storage/StorageTest.php +++ b/tests/Storage/StorageTest.php @@ -4,9 +4,9 @@ namespace Yiisoft\Yii\RateLimiter\Tests\Storage; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; use Yiisoft\Yii\RateLimiter\Storage\StorageInterface; -use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenTimeTimer; abstract class StorageTest extends TestCase { @@ -34,20 +34,20 @@ public function testGetKeyWithExistsKey(): void { $storage = $this->getStorage(); - $want = (new FrozenTimeTimer())->nowInMilliseconds(); + $want = round((float)(new DateTimeImmutable())->format('U.u') * 1000); $storage->saveIfNotExists('exists_key', $want, self::DEFAULT_TTL); $result = $storage->get('exists_key'); - $this->assertEquals($result, $want); + $this->assertEquals($want, $result); } public function testSaveIfNotExistsWithNewKey(): void { $storage = $this->getStorage(); - $value = (new FrozenTimeTimer())->nowInMilliseconds(); + $value = round((float)(new DateTimeImmutable())->format('U.u') * 1000); $result = $storage->saveIfNotExists('new_key', $value, self::DEFAULT_TTL); @@ -58,7 +58,7 @@ public function testSaveCompareAndSwapWithExistsKeyAndOldValueSame(): void { $storage = $this->getStorage(); - $oldValue = (new FrozenTimeTimer())->nowInMilliseconds(); + $oldValue = round((float)(new DateTimeImmutable())->format('U.u') * 1000); $storage->saveIfNotExists('exists_key', $oldValue, self::DEFAULT_TTL); $newValue = $oldValue + 100;