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 17 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
99 changes: 99 additions & 0 deletions src/RateLimiter/CacheCounter.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\SimpleCache\CacheInterface;

/**
* CacheCounter implements generic сell rate limit algorithm https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm
*/
final class CacheCounter
samdark marked this conversation as resolved.
Show resolved Hide resolved
{
private const MILLISECONDS_PER_SECOND = 1000;

private int $period;

private int $limit;

private ?string $id = null;

private CacheInterface $storage;

private int $arrivalTime;

public function __construct(int $limit, int $period, CacheInterface $storage)
{
$this->limit = $limit;
$this->period = $period * self::MILLISECONDS_PER_SECOND;
$this->storage = $storage;
}

public function setId(string $id): void
{
$this->id = $id;
}

public function limitIsReached(): bool
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
{
$this->checkParams();
$this->arrivalTime = $this->getArrivalTime();
$theoreticalArrivalTime = $this->calculateTheoreticalArrivalTime($this->getStorageValue());

if ($this->remainingEmpty($theoreticalArrivalTime)) {
return true;
}

$this->setStorageValue($theoreticalArrivalTime);

return false;
}

private function checkParams(): void
{
if ($this->id === null) {
throw new \RuntimeException('The counter id not set');
}

if ($this->limit < 1) {
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
throw new \InvalidArgumentException('The limit must be a positive value.');
}

if ($this->period < 1) {
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
throw new \InvalidArgumentException('The period must be a positive value.');
}
}

private function getEmissionInterval(): float
{
return (float)($this->period / $this->limit);
}

private function calculateTheoreticalArrivalTime(float $theoreticalArrivalTime): float
{
return max($this->arrivalTime, $theoreticalArrivalTime) + $this->getEmissionInterval();
}

private function remainingEmpty(float $theoreticalArrivalTime): bool
{
$allowAt = $theoreticalArrivalTime - $this->period;

return ((floor($this->arrivalTime - $allowAt) / $this->getEmissionInterval()) + 0.5) < 1;
}

private function getStorageValue(): float
{
return $this->storage->get($this->id, (float)$this->arrivalTime);
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
}

private function setStorageValue(float $theoreticalArrivalTime): void
{
$this->storage->set($this->id, $theoreticalArrivalTime);
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
}

private function getArrivalTime(): int
{
return time() * self::MILLISECONDS_PER_SECOND;
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
}
}
83 changes: 83 additions & 0 deletions src/RateLimiter/RateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?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;

/**
* RateLimiter limits the number of consequential requests ({@see CacheCounter::$period}) that could be processed per
* {@see CacheCounter::$limit}. If the number is reached, middleware responds with HTTP code 429, "Too Many Requests"
* until limit expires.
*/
final class RateLimiter implements MiddlewareInterface
{
private CacheCounter $counter;
samdark marked this conversation as resolved.
Show resolved Hide resolved

private ResponseFactoryInterface $responseFactory;

private string $counterId;

/**
* @var callable
*/
private $counterIdCallback;

public function __construct(CacheCounter $counter, ResponseFactoryInterface $responseFactory)
{
$this->counter = $counter;
$this->responseFactory = $responseFactory;
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->counter->setId($this->generateId($request));

if ($this->counter->limitIsReached()) {
return $this->createErrorResponse();
}

return $handler->handle($request);
}

public function withCounterIdCallback(callable $callback): self
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
{
$this->counterIdCallback = $callback;

return $this;
}

public function withCounterId(string $id): self
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
{
$this->counterId = $id;

return $this;
}

private function createErrorResponse(): ResponseInterface
{
$response = $this->responseFactory->createResponse(429);
romkatsu marked this conversation as resolved.
Show resolved Hide resolved
$response->getBody()->write('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('rate-limiter-' . $request->getMethod() . '-' . $request->getUri()->getPath());
}
}
68 changes: 68 additions & 0 deletions tests/RateLimiter/CacheCounterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Yiisoft\Yii\Web\Tests\RateLimiter;

use RuntimeException;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Yiisoft\Cache\ArrayCache;
use Yiisoft\Yii\Web\RateLimiter\CacheCounter;

final class CacheCounterTest extends TestCase
{
/**
* @test
*/
public function limitNotExhausted(): void
{
$counter = new CacheCounter(2, 5, new ArrayCache());
$counter->setId('key');

$this->assertFalse($counter->limitIsReached());
}

/**
* @test
*/
public function limitIsExhausted(): void
{
$cache = new ArrayCache();
$cache->set('key', (time() * 1000) + 55000);

$counter = new CacheCounter(10, 60, $cache);
$counter->setId('key');

$this->assertTrue($counter->limitIsReached());
}

/**
* @test
*/
public function invalidIdArgument(): void
{
$this->expectException(RuntimeException::class);
(new CacheCounter(10, 60, new ArrayCache()))->limitIsReached();
}

/**
* @test
*/
public function invalidLimitArgument(): void
{
$this->expectException(InvalidArgumentException::class);
$counter = new CacheCounter(0, 60, new ArrayCache());
$counter->setId('key');
$counter->limitIsReached();
}

/**
* @test
*/
public function invalidPeriodArgument(): void
{
$this->expectException(InvalidArgumentException::class);
$counter = new CacheCounter(10, 0, new ArrayCache());
$counter->setId('key');
$counter->limitIsReached();
}
}
118 changes: 118 additions & 0 deletions tests/RateLimiter/RateLimiterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace Yiisoft\Yii\Web\Tests\RateLimiter;

use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Yiisoft\Cache\ArrayCache;
use Yiisoft\Http\Method;
use Yiisoft\Yii\Web\RateLimiter\CacheCounter;
use Yiisoft\Yii\Web\RateLimiter\RateLimiter;

final class RateLimiterTest extends TestCase
{
/**
* @test
*/
public function singleRequestIsAllowed(): void
{
$middleware = $this->createRateLimiter($this->getCounter(1000));
$response = $middleware->process($this->createRequest(), $this->createRequestHandler());
$this->assertEquals(200, $response->getStatusCode());
}

/**
* @test
*/
public function moreThanDefaultNumberOfRequestsIsNotAllowed(): void
{
$middleware = $this->createRateLimiter($this->getCounter(1000));

for ($i = 0; $i < 999; $i++) {
$middleware->process($this->createRequest(), $this->createRequestHandler());
}

$response = $middleware->process($this->createRequest(), $this->createRequestHandler());
$this->assertEquals(429, $response->getStatusCode());
}

/**
* @test
*/
public function customLimitWorksAsExpected(): void
{
$middleware = $this->createRateLimiter($this->getCounter(10));

for ($i = 0; $i < 8; $i++) {
$middleware->process($this->createRequest(), $this->createRequestHandler());
}

$response = $middleware->process($this->createRequest(), $this->createRequestHandler());
$this->assertEquals(200, $response->getStatusCode());

$response = $middleware->process($this->createRequest(), $this->createRequestHandler());
$this->assertEquals(429, $response->getStatusCode());
}

/**
* @test
*/
public function withManualCounterId(): void
{
$cache = new ArrayCache();
$counter = new CacheCounter(100, 3600, $cache);

$middleware = $this->createRateLimiter($counter)->withCounterId('custom-id');
$middleware->process($this->createRequest(), $this->createRequestHandler());

$this->assertTrue($cache->has('custom-id'));
}

/**
* @test
*/
public function withManualCounterByCallback(): void
{
$cache = new ArrayCache();
$counter = new CacheCounter(100, 3600, $cache);

$middleware = $this->createRateLimiter($counter)->withCounterIdCallback(
static function (ServerRequestInterface $request) {
return $request->getMethod();
}
);

$middleware->process($this->createRequest(), $this->createRequestHandler());
$this->assertTrue($cache->has('GET'));
}

private function getCounter(int $limit): CacheCounter
{
return new CacheCounter($limit, 3600, new ArrayCache());
}

private function createRequestHandler(): RequestHandlerInterface
{
return new class implements RequestHandlerInterface {
public function handle(ServerRequestInterface $request): ResponseInterface
{
return new Response(200);
}
};
}

private function createRequest(string $method = Method::GET, string $uri = '/'): ServerRequestInterface
{
return new ServerRequest($method, $uri);
}

private function createRateLimiter(CacheCounter $counter): RateLimiter
{
return new RateLimiter($counter, new Psr17Factory());
}
}