Skip to content

Commit

Permalink
Fix #43: Add APCu counters storage, fix #41: Adapt package to concurr…
Browse files Browse the repository at this point in the history
…ent requests, for this `StorageInterface` method `save()` split to `saveIfNotExists()` and `saveCompareAndSwap()`
  • Loading branch information
jiaweipan authored Jul 25, 2023
1 parent 93e9ad4 commit cc9b656
Show file tree
Hide file tree
Showing 31 changed files with 882 additions and 184 deletions.
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ indent_style = space
indent_size = 4
trim_trailing_whitespace = true

[*.php]
ij_php_space_before_short_closure_left_parenthesis = false
ij_php_space_after_type_cast = true

[*.md]
trim_trailing_whitespace = false

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
phpunit:
uses: yiisoft/actions/.github/workflows/phpunit.yml@master
with:
extensions: apcu
ini-values: apc.enabled=1,apc.shm_size=32M,apc.enable_cli=1
os: >-
['ubuntu-latest', 'windows-latest']
php: >-
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
mutation:
uses: yiisoft/actions/.github/workflows/roave-infection.yml@master
with:
extensions: apcu
ini-values: apc.enabled=1,apc.shm_size=32M,apc.enable_cli=1
os: >-
['ubuntu-latest']
php: >-
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/rector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ name: rector
jobs:
rector:
uses: yiisoft/actions/.github/workflows/rector.yml@master
secrets:
token: ${{ secrets.YIISOFT_GITHUB_TOKEN }}
with:
os: >-
['ubuntu-latest']
Expand Down
12 changes: 8 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
# Yii Rate Limiter Change Log

## 2.0.1 under development
## 3.0.0 under development

- 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)
- Enh #25: Raise minimum `PHP` version to `8.0` (@terabytesoftw)
- Chg #21: Update `yiisoft/http` dependency (devanych)
- Chg #21: Update `yiisoft/http` dependency (@devanych)

## 2.0.0 August 15, 2021

- Enh #19: Introduce `LimitPolicyInterface`, `StorageInterface`, `TimerInterface`. Rename `Middleware` to `LimitRequestsMiddleware` (kafkiansky, samdark)
- Enh #19: Introduce `LimitPolicyInterface`, `StorageInterface`, `TimerInterface`. Rename `Middleware` to
`LimitRequestsMiddleware` (@kafkiansky, @samdark)

## 1.0.1 June 08, 2021

- Bug #14: Throw exception on call `getCacheKey()` in counter without the specified ID (vjik)
- Bug #14: Throw exception on call `getCacheKey()` in counter without the specified ID (@vjik)

## 1.0.0 June 07, 2021

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ Another way it to implement `Yiisoft\Yii\RateLimiter\Policy\LimitPolicyInterface

### Implementing your own counter storage

By default, the package provides `\Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage` that stores counters
in any [PSR-16](https://www.php-fig.org/psr/psr-16/) cache. To have your own storage
implement `Yiisoft\Yii\RateLimiter\Storage\StorageInterface`.
There are two ready to use counter storages available in the package:
- `\Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage` - stores counters in any [PSR-16](https://www.php-fig.org/psr/psr-16/) cache.
- `\Yiisoft\Yii\RateLimiter\Storage\ApcuStorage` - stores counters by using the [APCu PHP extension](http://www.php.net/apcu) while taking concurrency into account.

To use your own storage implement `Yiisoft\Yii\RateLimiter\Storage\StorageInterface`.

## Testing

Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"yiisoft/http": "^1.2"
},
"require-dev": {
"ext-apcu": "*",
"nyholm/psr7": "^1.0",
"phpunit/phpunit": "^9.5",
"rector/rector": "^0.15.2",
Expand All @@ -39,6 +40,9 @@
"vimeo/psalm": "^4.18",
"yiisoft/cache": "^2.0"
},
"suggest": {
"ext-apcu": "To use APCu storage"
},
"autoload": {
"psr-4": {
"Yiisoft\\Yii\\RateLimiter\\": "src"
Expand Down
7 changes: 7 additions & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Rector\CodeQuality\Rector\Class_\InlineConstructorDefaultToPropertyRector;
use Rector\Config\RectorConfig;
use Rector\Php56\Rector\FunctionLike\AddDefaultValueForUndefinedVariableRector;
use Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector;
use Rector\Set\ValueObject\LevelSetList;

return static function (RectorConfig $rectorConfig): void {
Expand All @@ -19,4 +21,9 @@
$rectorConfig->sets([
LevelSetList::UP_TO_PHP_80,
]);

$rectorConfig->skip([
ClosureToArrowFunctionRector::class,
AddDefaultValueForUndefinedVariableRector::class,
]);
};
116 changes: 81 additions & 35 deletions src/Counter.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ final class Counter implements CounterInterface
private const DEFAULT_TTL = 86400;
private const ID_PREFIX = 'rate-limiter-';
private const MILLISECONDS_PER_SECOND = 1000;
private const DEFAULT_MAX_CAS_ATTEMPTS = 10;

/**
* @var int Period to apply limit to.
Expand All @@ -40,14 +41,16 @@ final class Counter implements CounterInterface
* @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 int $maxCasAttempts Maximum number of times to retry saveIfNotExists/saveCompareAndSwap operations before returning an error.
*/
public function __construct(
private StorageInterface $storage,
private int $limit,
private int $periodInSeconds,
int $periodInSeconds,
private int $storageTtlInSeconds = self::DEFAULT_TTL,
private string $storagePrefix = self::ID_PREFIX,
TimerInterface|null $timer = null
TimerInterface|null $timer = null,
private int $maxCasAttempts = self::DEFAULT_MAX_CAS_ATTEMPTS,
) {
if ($limit < 1) {
throw new InvalidArgumentException('The limit must be a positive value.');
Expand All @@ -57,7 +60,6 @@ public function __construct(
throw new InvalidArgumentException('The period must be a positive value.');
}

$this->limit = $limit;
$this->periodInMilliseconds = $periodInSeconds * self::MILLISECONDS_PER_SECOND;
$this->timer = $timer ?: new MicrotimeTimer();
$this->incrementIntervalInMilliseconds = $this->periodInMilliseconds / $this->limit;
Expand All @@ -68,58 +70,102 @@ public function __construct(
*/
public function hit(string $id): CounterState
{
// Last increment time.
// In GCRA it's known as arrival time.
$lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds();

$theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
$lastIncrementTimeInMilliseconds,
$this->getLastStoredTheoreticalNextIncrementTime($id, $lastIncrementTimeInMilliseconds)
);

$remaining = $this->calculateRemaining($lastIncrementTimeInMilliseconds, $theoreticalNextIncrementTime);
$resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);

if ($remaining >= 1) {
$this->storeTheoreticalNextIncrementTime($id, $theoreticalNextIncrementTime);
}

return new CounterState($this->limit, $remaining, $resetAfter);
$attempts = 0;
$isFailStoreUpdatedData = false;

Check warning on line 74 in src/Counter.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "FalseValue": --- Original +++ New @@ @@ public function hit(string $id) : CounterState { $attempts = 0; - $isFailStoreUpdatedData = false; + $isFailStoreUpdatedData = true; do { // Last increment time. // In GCRA it's known as arrival time.
do {
// Last increment time.
// In GCRA it's known as arrival time.
$lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds();

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

$theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
$lastIncrementTimeInMilliseconds,
$lastStoredTheoreticalNextIncrementTime
);

$remaining = $this->calculateRemaining($lastIncrementTimeInMilliseconds, $theoreticalNextIncrementTime);
$resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);

if ($remaining === 0) {
break;
}

$isStored = $this->storeTheoreticalNextIncrementTime(
$id,
$theoreticalNextIncrementTime,
$lastStoredTheoreticalNextIncrementTime
);
if ($isStored) {
break;
}

$attempts++;
if ($attempts >= $this->maxCasAttempts) {
$isFailStoreUpdatedData = true;
break;

Check warning on line 106 in src/Counter.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "Break_": --- Original +++ New @@ @@ $attempts++; if ($attempts >= $this->maxCasAttempts) { $isFailStoreUpdatedData = true; - break; + continue; } } while (true); return new CounterState($this->limit, $remaining, $resetAfter, $isFailStoreUpdatedData);
}
} while (true);

return new CounterState($this->limit, $remaining, $resetAfter, $isFailStoreUpdatedData);
}

/**
* @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.
*/
private function calculateTheoreticalNextIncrementTime(
int $lastIncrementTimeInMilliseconds,
float $storedTheoreticalNextIncrementTime
float $lastIncrementTimeInMilliseconds,
?float $storedTheoreticalNextIncrementTime
): float {
return max($lastIncrementTimeInMilliseconds, $storedTheoreticalNextIncrementTime) +
$this->incrementIntervalInMilliseconds;
return (
$storedTheoreticalNextIncrementTime === null
? $lastIncrementTimeInMilliseconds
: max($lastIncrementTimeInMilliseconds, $storedTheoreticalNextIncrementTime)
) + $this->incrementIntervalInMilliseconds;
}

/**
* @return int The number of remaining requests in the current time period.
*/
private function calculateRemaining(int $lastIncrementTimeInMilliseconds, float $theoreticalNextIncrementTime): int
{
private function calculateRemaining(
float $lastIncrementTimeInMilliseconds,
float $theoreticalNextIncrementTime
): int {
$incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds;

return (int) (
round($lastIncrementTimeInMilliseconds - $incrementAllowedAt) /
$this->incrementIntervalInMilliseconds
);
$remainingTimeInMilliseconds = round($lastIncrementTimeInMilliseconds - $incrementAllowedAt);

Check warning on line 137 in src/Counter.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "RoundingFamily": --- Original +++ New @@ @@ private function calculateRemaining(float $lastIncrementTimeInMilliseconds, float $theoreticalNextIncrementTime) : int { $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds; - $remainingTimeInMilliseconds = round($lastIncrementTimeInMilliseconds - $incrementAllowedAt); + $remainingTimeInMilliseconds = floor($lastIncrementTimeInMilliseconds - $incrementAllowedAt); if ($remainingTimeInMilliseconds > 0) { return (int) ($remainingTimeInMilliseconds / $this->incrementIntervalInMilliseconds); }

Check warning on line 137 in src/Counter.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "RoundingFamily": --- Original +++ New @@ @@ private function calculateRemaining(float $lastIncrementTimeInMilliseconds, float $theoreticalNextIncrementTime) : int { $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds; - $remainingTimeInMilliseconds = round($lastIncrementTimeInMilliseconds - $incrementAllowedAt); + $remainingTimeInMilliseconds = ceil($lastIncrementTimeInMilliseconds - $incrementAllowedAt); if ($remainingTimeInMilliseconds > 0) { return (int) ($remainingTimeInMilliseconds / $this->incrementIntervalInMilliseconds); }
if ($remainingTimeInMilliseconds > 0) {

Check warning on line 138 in src/Counter.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "GreaterThan": --- Original +++ New @@ @@ { $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds; $remainingTimeInMilliseconds = round($lastIncrementTimeInMilliseconds - $incrementAllowedAt); - if ($remainingTimeInMilliseconds > 0) { + if ($remainingTimeInMilliseconds >= 0) { return (int) ($remainingTimeInMilliseconds / $this->incrementIntervalInMilliseconds); } return 0;
return (int) ($remainingTimeInMilliseconds / $this->incrementIntervalInMilliseconds);
}

return 0;
}

private function getLastStoredTheoreticalNextIncrementTime(string $id, int $lastIncrementTimeInMilliseconds): float
private function getLastStoredTheoreticalNextIncrementTime(string $id): ?float
{
return (float) $this->storage->get($this->getStorageKey($id), $lastIncrementTimeInMilliseconds);
return $this->storage->get($this->getStorageKey($id));
}

private function storeTheoreticalNextIncrementTime(string $id, float $theoreticalNextIncrementTime): void
{
$this->storage->save($this->getStorageKey($id), $theoreticalNextIncrementTime, $this->storageTtlInSeconds);
private function storeTheoreticalNextIncrementTime(
string $id,
float $theoreticalNextIncrementTime,
?float $lastStoredTheoreticalNextIncrementTime
): bool {
if ($lastStoredTheoreticalNextIncrementTime !== null) {
return $this->storage->saveCompareAndSwap(
$this->getStorageKey($id),
$lastStoredTheoreticalNextIncrementTime,
$theoreticalNextIncrementTime,
$this->storageTtlInSeconds
);
}

return $this->storage->saveIfNotExists(
$this->getStorageKey($id),
$theoreticalNextIncrementTime,
$this->storageTtlInSeconds
);
}

/**
Expand Down
17 changes: 15 additions & 2 deletions src/CounterState.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ final class CounterState
* @param int $limit The maximum number of requests allowed with a time period.
* @param int $remaining The number of remaining requests in the current time period.
* @param int $resetTime Timestamp to wait until the rate limit resets.
* @param bool $isFailStoreUpdatedData If fail to store updated the rate limit data.
*/
public function __construct(private int $limit, private int $remaining, private int $resetTime)
{
public function __construct(
private int $limit,
private int $remaining,
private int $resetTime,
private bool $isFailStoreUpdatedData = false

Check warning on line 22 in src/CounterState.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "FalseValue": --- Original +++ New @@ @@ * @param int $resetTime Timestamp to wait until the rate limit resets. * @param bool $isFailStoreUpdatedData If fail to store updated the rate limit data. */ - public function __construct(private int $limit, private int $remaining, private int $resetTime, private bool $isFailStoreUpdatedData = false) + public function __construct(private int $limit, private int $remaining, private int $resetTime, private bool $isFailStoreUpdatedData = true) { } /**
) {
}

/**
Expand Down Expand Up @@ -49,4 +54,12 @@ public function isLimitReached(): bool
{
return $this->remaining === 0;
}

/**
* @return bool If fail to store updated the rate limit data.
*/
public function isFailStoreUpdatedData(): bool
{
return $this->isFailStoreUpdatedData;
}
}
11 changes: 7 additions & 4 deletions src/LimitRequestsMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ final class LimitRequestsMiddleware implements MiddlewareInterface
public function __construct(
private CounterInterface $counter,
private ResponseFactoryInterface $responseFactory,
LimitPolicyInterface|null $limitingPolicy = null
LimitPolicyInterface|null $limitingPolicy = null,
private ?MiddlewareInterface $failStoreUpdatedDataMiddleware = null,
) {
$this->limitingPolicy = $limitingPolicy ?: new LimitPerIp();
}
Expand All @@ -40,6 +41,8 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface

if ($state->isLimitReached()) {
$response = $this->createErrorResponse();
} elseif ($state->isFailStoreUpdatedData() && $this->failStoreUpdatedDataMiddleware !== null) {
$response = $this->failStoreUpdatedDataMiddleware->process($request, $handler);
} else {
$response = $handler->handle($request);
}
Expand All @@ -58,8 +61,8 @@ private function createErrorResponse(): ResponseInterface
private function addHeaders(ResponseInterface $response, CounterState $result): ResponseInterface
{
return $response
->withHeader('X-Rate-Limit-Limit', (string)$result->getLimit())
->withHeader('X-Rate-Limit-Remaining', (string)$result->getRemaining())
->withHeader('X-Rate-Limit-Reset', (string)$result->getResetTime());
->withHeader('X-Rate-Limit-Limit', (string) $result->getLimit())
->withHeader('X-Rate-Limit-Remaining', (string) $result->getRemaining())
->withHeader('X-Rate-Limit-Reset', (string) $result->getResetTime());
}
}
Loading

0 comments on commit cc9b656

Please sign in to comment.