This repository has been archived by the owner on Jun 29, 2022. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 46
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implemented GCRA-based rate limiter (#204)
Co-authored-by: Alexander Makarov <[email protected]>
- Loading branch information
Showing
7 changed files
with
581 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Yiisoft\Yii\Web\RateLimiter; | ||
|
||
use Psr\SimpleCache\CacheInterface; | ||
|
||
/** | ||
* Counter implements generic сell rate limit algorithm (GCRA) that ensures that after reaching the limit futher | ||
* increments are distributed equally. | ||
* | ||
* @link https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm | ||
*/ | ||
final class Counter implements CounterInterface | ||
{ | ||
private const DEFAULT_TTL = 86400; | ||
|
||
private const ID_PREFIX = 'rate-limiter-'; | ||
|
||
private const MILLISECONDS_PER_SECOND = 1000; | ||
|
||
/** | ||
* @var int period to apply limit to, in milliseconds | ||
*/ | ||
private int $period; | ||
|
||
private int $limit; | ||
|
||
/** | ||
* @var float maximum interval before next increment, in milliseconds | ||
* In GCRA it is known as emission interval. | ||
*/ | ||
private float $incrementInterval; | ||
|
||
private ?string $id = null; | ||
|
||
private CacheInterface $storage; | ||
|
||
private int $ttl = self::DEFAULT_TTL; | ||
|
||
/** | ||
* @var int last increment time | ||
* In GCRA it's known as arrival time | ||
*/ | ||
private int $lastIncrementTime; | ||
|
||
/** | ||
* @param int $limit maximum number of increments that could be performed before increments are limited | ||
* @param int $period period to apply limit to, in seconds | ||
* @param CacheInterface $storage | ||
*/ | ||
public function __construct(int $limit, int $period, CacheInterface $storage) | ||
{ | ||
if ($limit < 1) { | ||
throw new \InvalidArgumentException('The limit must be a positive value.'); | ||
} | ||
|
||
if ($period < 1) { | ||
throw new \InvalidArgumentException('The period must be a positive value.'); | ||
} | ||
|
||
$this->limit = $limit; | ||
$this->period = $period * self::MILLISECONDS_PER_SECOND; | ||
$this->storage = $storage; | ||
|
||
$this->incrementInterval = (float)($this->period / $this->limit); | ||
} | ||
|
||
public function setId(string $id): void | ||
{ | ||
$this->id = $id; | ||
} | ||
|
||
/** | ||
* @param int $ttl cache TTL that is used to store counter values | ||
* Default is one day. | ||
* Note that period can not exceed TTL. | ||
*/ | ||
public function setTtl(int $ttl): void | ||
{ | ||
$this->ttl = $ttl; | ||
} | ||
|
||
public function getCacheKey(): string | ||
{ | ||
return self::ID_PREFIX . $this->id; | ||
} | ||
|
||
public function incrementAndGetState(): CounterState | ||
{ | ||
if ($this->id === null) { | ||
throw new \LogicException('The counter ID should be set'); | ||
} | ||
|
||
$this->lastIncrementTime = $this->getCurrentTime(); | ||
$theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime( | ||
$this->getLastStoredTheoreticalNextIncrementTime() | ||
); | ||
$remaining = $this->calculateRemaining($theoreticalNextIncrementTime); | ||
$resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime); | ||
|
||
if ($remaining >= 1) { | ||
$this->storeTheoreticalNextIncrementTime($theoreticalNextIncrementTime); | ||
} | ||
|
||
return new CounterState($this->limit, $remaining, $resetAfter); | ||
} | ||
|
||
/** | ||
* @param float $storedTheoreticalNextIncrementTime | ||
* @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(float $storedTheoreticalNextIncrementTime): float | ||
{ | ||
return max($this->lastIncrementTime, $storedTheoreticalNextIncrementTime) + $this->incrementInterval; | ||
} | ||
|
||
/** | ||
* @param float $theoreticalNextIncrementTime | ||
* @return int the number of remaining requests in the current time period | ||
*/ | ||
private function calculateRemaining(float $theoreticalNextIncrementTime): int | ||
{ | ||
$incrementAllowedAt = $theoreticalNextIncrementTime - $this->period; | ||
|
||
return (int)(round($this->lastIncrementTime - $incrementAllowedAt) / $this->incrementInterval); | ||
} | ||
|
||
private function getLastStoredTheoreticalNextIncrementTime(): float | ||
{ | ||
return $this->storage->get($this->getCacheKey(), (float)$this->lastIncrementTime); | ||
} | ||
|
||
private function storeTheoreticalNextIncrementTime(float $theoreticalNextIncrementTime): void | ||
{ | ||
$this->storage->set($this->getCacheKey(), $theoreticalNextIncrementTime, $this->ttl); | ||
} | ||
|
||
/** | ||
* @param float $theoreticalNextIncrementTime | ||
* @return int timestamp to wait until the rate limit resets | ||
*/ | ||
private function calculateResetAfter(float $theoreticalNextIncrementTime): int | ||
{ | ||
return (int)($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND); | ||
} | ||
|
||
private function getCurrentTime(): int | ||
{ | ||
return (int)round(microtime(true) * self::MILLISECONDS_PER_SECOND); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Yiisoft\Yii\Web\RateLimiter; | ||
|
||
/** | ||
* CounterInterface describes rate limiter counter | ||
*/ | ||
interface CounterInterface | ||
{ | ||
/** | ||
* @param string $id set counter ID | ||
* Counters with non-equal IDs are counted separately. | ||
*/ | ||
public function setId(string $id): void; | ||
|
||
/** | ||
* Counts one request as done and returns object containing current counter state | ||
* @return CounterState | ||
*/ | ||
public function incrementAndGetState(): CounterState; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Yiisoft\Yii\Web\RateLimiter; | ||
|
||
/** | ||
* Rate limiter counter statistics | ||
*/ | ||
final class CounterState | ||
{ | ||
private int $limit; | ||
private int $remaining; | ||
private int $reset; | ||
|
||
/** | ||
* @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 $reset timestamp to wait until the rate limit resets | ||
*/ | ||
public function __construct(int $limit, int $remaining, int $reset) | ||
{ | ||
$this->limit = $limit; | ||
$this->remaining = $remaining; | ||
$this->reset = $reset; | ||
} | ||
|
||
/** | ||
* @return int the maximum number of requests allowed with a time period | ||
*/ | ||
public function getLimit(): int | ||
{ | ||
return $this->limit; | ||
} | ||
|
||
/** | ||
* @return int the number of remaining requests in the current time period | ||
*/ | ||
public function getRemaining(): int | ||
{ | ||
return $this->remaining; | ||
} | ||
|
||
/** | ||
* @return int timestamp to wait until the rate limit resets | ||
*/ | ||
public function getResetTime(): int | ||
{ | ||
return $this->reset; | ||
} | ||
|
||
/** | ||
* @return bool if requests limit is reached | ||
*/ | ||
public function isLimitReached(): bool | ||
{ | ||
return $this->remaining === 0; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Yiisoft\Yii\Web\RateLimiter; | ||
|
||
use Psr\Http\Message\ResponseFactoryInterface; | ||
use Psr\Http\Message\ResponseInterface; | ||
use Psr\Http\Message\ServerRequestInterface; | ||
use Psr\Http\Server\MiddlewareInterface; | ||
use Psr\Http\Server\RequestHandlerInterface; | ||
use Yiisoft\Http\Status; | ||
|
||
/** | ||
* RateLimiter helps to prevent abuse by limiting the number of requests that could be me 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 | ||
* (meaning "Too Many Requests") should be returned. | ||
*/ | ||
final class RateLimiterMiddleware implements MiddlewareInterface | ||
{ | ||
private CounterInterface $counter; | ||
|
||
private ResponseFactoryInterface $responseFactory; | ||
|
||
private string $counterId; | ||
|
||
/** | ||
* @var callable | ||
*/ | ||
private $counterIdCallback; | ||
|
||
public function __construct(CounterInterface $counter, ResponseFactoryInterface $responseFactory) | ||
{ | ||
$this->counter = $counter; | ||
$this->responseFactory = $responseFactory; | ||
} | ||
|
||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface | ||
{ | ||
$this->counter->setId($this->generateId($request)); | ||
$result = $this->counter->incrementAndGetState(); | ||
|
||
if ($result->isLimitReached()) { | ||
$response = $this->createErrorResponse(); | ||
} else { | ||
$response = $handler->handle($request); | ||
} | ||
|
||
return $this->addHeaders($response, $result); | ||
} | ||
|
||
public function withCounterIdCallback(?callable $callback): self | ||
{ | ||
$new = clone $this; | ||
$new->counterIdCallback = $callback; | ||
|
||
return $new; | ||
} | ||
|
||
public function withCounterId(string $id): self | ||
{ | ||
$new = clone $this; | ||
$new->counterId = $id; | ||
|
||
return $new; | ||
} | ||
|
||
private function createErrorResponse(): ResponseInterface | ||
{ | ||
$response = $this->responseFactory->createResponse(Status::TOO_MANY_REQUESTS); | ||
$response->getBody()->write(Status::TEXTS[Status::TOO_MANY_REQUESTS]); | ||
|
||
return $response; | ||
} | ||
|
||
private function generateId(ServerRequestInterface $request): string | ||
{ | ||
if ($this->counterIdCallback !== null) { | ||
return \call_user_func($this->counterIdCallback, $request); | ||
} | ||
|
||
return $this->counterId ?? $this->generateIdFromRequest($request); | ||
} | ||
|
||
private function generateIdFromRequest(ServerRequestInterface $request): string | ||
{ | ||
return strtolower($request->getMethod() . '-' . $request->getUri()->getPath()); | ||
} | ||
|
||
private function addHeaders(ResponseInterface $response, CounterState $result): ResponseInterface | ||
{ | ||
return $response | ||
->withHeader('X-Rate-Limit-Limit', $result->getLimit()) | ||
->withHeader('X-Rate-Limit-Remaining', $result->getRemaining()) | ||
->withHeader('X-Rate-Limit-Reset', $result->getResetTime()); | ||
} | ||
} |
Oops, something went wrong.