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

Rate limiter implementation(gcra) #204

Merged
merged 36 commits into from
Jan 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6b86901
Merge pull request #1 from yiisoft/master
romkatsu Sep 22, 2019
bde0a00
Merge pull request #2 from yiisoft/master
romkatsu Oct 20, 2019
532d758
Merge pull request #3 from yiisoft/master
romkatsu Oct 29, 2019
312ca3a
Merge pull request #4 from yiisoft/master
romkatsu Dec 22, 2019
7245614
Merge pull request #5 from yiisoft/master
romkatsu Jan 5, 2020
b535aae
add rate limiter clases
romkatsu Jan 9, 2020
f74ae48
fix use block
romkatsu Jan 9, 2020
c137b78
fix quota limit
romkatsu Jan 9, 2020
b33ac0f
remove counter interface and quota
romkatsu Jan 10, 2020
ac2d299
add tests
romkatsu Jan 10, 2020
0177222
fix doc block
romkatsu Jan 10, 2020
f21365c
add counter class tests
romkatsu Jan 10, 2020
535c684
fix name methods
romkatsu Jan 10, 2020
f84c84e
fix name methods
romkatsu Jan 10, 2020
2792915
fixed doc block
romkatsu Jan 11, 2020
f19ec40
corrected the calculations in milliseconds
romkatsu Jan 11, 2020
1379280
fix float value
romkatsu Jan 11, 2020
124c27d
meve check to constructor
romkatsu Jan 11, 2020
26a4d61
add cache prefix and fix setters
romkatsu Jan 11, 2020
1e92598
add RateLimitResult and edit middleware
romkatsu Jan 12, 2020
7f8b720
Add phpdoc to RateLimitResult, adjust naming
samdark Jan 13, 2020
487770d
Adjust RateLimiterMiddleware description
samdark Jan 13, 2020
e0b9d7e
add interface counter and fix method
romkatsu Jan 13, 2020
6aacd61
fix tests
romkatsu Jan 13, 2020
2b707e4
Better naming
samdark Jan 13, 2020
5d3f5a4
Refactoring of Counter
samdark Jan 13, 2020
3a775e8
Remove useless condition
samdark Jan 13, 2020
16350a6
Added phpdoc to Counter for better understanding or algorithm, adjust…
samdark Jan 13, 2020
b3fb5c8
Adjust naming in tests, remove duplicate test
samdark Jan 13, 2020
e7fa4bb
Do rounding with native PHP function
samdark Jan 14, 2020
77fce0b
Add Counter::getCacheKey()
samdark Jan 14, 2020
c8b480c
add tests and edit reset time, fix lastIncrementTime in Counter
romkatsu Jan 15, 2020
108be0e
edit reset method add timestamp returned
romkatsu Jan 15, 2020
ac0c8de
Rename CounterStatistics -> CounterState
samdark Jan 16, 2020
95e9272
Add cache TTL
samdark Jan 16, 2020
00b035a
simplified testing middleware
romkatsu Jan 17, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
samdark marked this conversation as resolved.
Show resolved Hide resolved

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
samdark marked this conversation as resolved.
Show resolved Hide resolved
{
/**
* @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);
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
}

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