Skip to content

Commit

Permalink
Fix #44: Use PSR-20 ClockInterface instead of TimerInterface
Browse files Browse the repository at this point in the history
  • Loading branch information
samdark committed Jul 25, 2023
1 parent b852afa commit f7cef39
Show file tree
Hide file tree
Showing 15 changed files with 95 additions and 102 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
}));
```

Expand Down Expand Up @@ -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/).
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
22 changes: 12 additions & 10 deletions src/Counter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace Yiisoft\Yii\RateLimiter;

use _PHPStan_d55c4f2c2\React\Http\Io\Clock;
use InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;
use Yiisoft\Yii\RateLimiter\Time\MicrotimeTimer;
use Yiisoft\Yii\RateLimiter\Time\SystemClock;
use Yiisoft\Yii\RateLimiter\Time\TimerInterface;

/**
Expand All @@ -28,19 +30,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(
Expand All @@ -49,7 +51,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) {
Expand All @@ -61,7 +63,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;
}

Expand All @@ -75,7 +77,7 @@ public function hit(string $id): CounterState
do {
// Last increment time.
// In GCRA it's known as arrival time.
$lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds();
$lastIncrementTimeInMilliseconds = $this->timer->now()->format('U.u') * 1000;

$lastStoredTheoreticalNextIncrementTime = $this->getLastStoredTheoreticalNextIncrementTime($id);

Expand Down Expand Up @@ -112,7 +114,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,
Expand Down
15 changes: 0 additions & 15 deletions src/Time/MicrotimeTimer.php

This file was deleted.

16 changes: 16 additions & 0 deletions src/Time/SystemClock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Yii\RateLimiter\Time;

use DateTimeImmutable;
use Psr\Clock\ClockInterface;

final class SystemClock implements ClockInterface
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
}
10 changes: 0 additions & 10 deletions src/Time/TimerInterface.php

This file was deleted.

6 changes: 3 additions & 3 deletions tests/ApcuCounterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Yiisoft\Yii\RateLimiter\Storage\ApcuStorage;
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FakeApcuStorage;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenTimeTimer;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenClock;

final class ApcuCounterTest extends BaseCounterTest
{
Expand All @@ -35,7 +35,7 @@ protected function setUp(): void
*/
public function testConcurrentHitsWithDirtyReading(): void
{
$timer = new FrozenTimeTimer();
$timer = new FrozenClock();
$storage = new FakeApcuStorage(5);
$limitHits = 10;
$counter = new Counter(
Expand Down Expand Up @@ -64,7 +64,7 @@ public function testConcurrentHitsWithDirtyReading(): void

public function testIsExceedingMaxAttempts(): void
{
$timer = new FrozenTimeTimer();
$timer = new FrozenClock();
$dirtyReadCount = 2;
$storage = new FakeApcuStorage($dirtyReadCount);
$counter = new Counter(
Expand Down
15 changes: 9 additions & 6 deletions tests/BaseCounterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

namespace Yiisoft\Yii\RateLimiter\Tests;

use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Yiisoft\Yii\RateLimiter\Counter;
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenTimeTimer;
use Yiisoft\Yii\RateLimiter\Time\MicrotimeTimer;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenClock;
use Yiisoft\Yii\RateLimiter\Time\SystemClock;
use Yiisoft\Yii\RateLimiter\Tests\Support\Assert;

abstract class BaseCounterTest extends TestCase
Expand Down Expand Up @@ -54,7 +55,8 @@ public function testShouldNotBeAbleToSetInvalidPeriod(): void

public function testIncrementMustBeUniformAfterLimitIsReached(): void
{
$timer = new FrozenTimeTimer();
$timer = new FrozenClock();

$counter = new Counter(
$this->getStorage(),
10,
Expand All @@ -72,7 +74,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());
}
Expand All @@ -82,18 +84,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'));
}
Expand Down
6 changes: 3 additions & 3 deletions tests/CounterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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(
Expand Down
30 changes: 30 additions & 0 deletions tests/Fixtures/FrozenClock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Yii\RateLimiter\Tests\Fixtures;

use DateTimeImmutable;
use Psr\Clock\ClockInterface;

/**
* Frozen timer returns the same value for all calls.
*/
final class FrozenClock implements ClockInterface
{

private DateTimeImmutable $now;
public function __construct()
{
$this->now = new DateTimeImmutable();
}
public function now(): DateTimeImmutable
{
return $this->now;
}

public function modify(string $modifier): void
{
$this->now = $this->now->modify($modifier);
}
}
37 changes: 0 additions & 37 deletions tests/Fixtures/FrozenTimeTimer.php

This file was deleted.

7 changes: 4 additions & 3 deletions tests/MiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\Yii\RateLimiter\Tests;

use DateTimeImmutable;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
Expand All @@ -25,7 +26,7 @@
use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FakeApcuStorage;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FakeCounter;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenTimeTimer;
use Yiisoft\Yii\RateLimiter\Tests\Fixtures\FrozenClock;

final class MiddlewareTest extends TestCase
{
Expand Down Expand Up @@ -227,7 +228,7 @@ public function testWithLimitingFunction(): void
*/
public function testWithExceedingMaxAttempts(): void
{
$timer = new FrozenTimeTimer();
$timer = new FrozenClock();
$dirtyReadCount = 2;
$storage = new FakeApcuStorage($dirtyReadCount);
$counter = new Counter(
Expand Down Expand Up @@ -259,7 +260,7 @@ public function testWithExceedingMaxAttempts(): void

public function testFailStoreUpdatedDataMiddleware(): void
{
$timer = new FrozenTimeTimer();
$timer = new FrozenClock();
$dirtyReadCount = 2;
$storage = new FakeApcuStorage($dirtyReadCount);
$counter = new Counter(
Expand Down
Loading

0 comments on commit f7cef39

Please sign in to comment.