Skip to content
This repository has been archived by the owner on Jun 29, 2022. It is now read-only.

Commit

Permalink
Implemented GCRA-based rate limiter (#204)
Browse files Browse the repository at this point in the history
Co-authored-by: Alexander Makarov <[email protected]>
  • Loading branch information
2 people authored and devanych committed Feb 1, 2021
1 parent 8034726 commit 1d69670
Show file tree
Hide file tree
Showing 7 changed files with 581 additions and 0 deletions.
154 changes: 154 additions & 0 deletions src/RateLimiter/Counter.php
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);
}
}
23 changes: 23 additions & 0 deletions src/RateLimiter/CounterInterface.php
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;
}
59 changes: 59 additions & 0 deletions src/RateLimiter/CounterState.php
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;
}
}
99 changes: 99 additions & 0 deletions src/RateLimiter/RateLimiterMiddleware.php
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());
}
}
Loading

0 comments on commit 1d69670

Please sign in to comment.