[RateLimiter] Adding SlidingWindow algorithm

This commit is contained in:
Nyholm 2020-10-14 11:18:30 +02:00 committed by Fabien Potencier
parent 8430954bce
commit c6d3b70315
7 changed files with 352 additions and 3 deletions

View File

@ -1817,14 +1817,14 @@ class Configuration implements ConfigurationInterface
->enumNode('strategy')
->info('The rate limiting algorithm to use for this rate')
->isRequired()
->values(['fixed_window', 'token_bucket'])
->values(['fixed_window', 'token_bucket', 'sliding_window'])
->end()
->integerNode('limit')
->info('The maximum allowed hits in a fixed interval or burst')
->isRequired()
->end()
->scalarNode('interval')
->info('Configures the fixed interval if "strategy" is set to "fixed_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).')
->info('Configures the fixed interval if "strategy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).')
->end()
->arrayNode('rate')
->info('Configures the fill rate if "strategy" is set to "token_bucket"')

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Exception;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @experimental in 5.2
*/
class InvalidIntervalException extends \LogicException
{
}

View File

@ -51,8 +51,11 @@ final class Limiter
case 'fixed_window':
return new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock);
case 'sliding_window':
return new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock);
default:
throw new \LogicException(sprintf('Limiter strategy "%s" does not exists, it must be either "token_bucket" or "fixed_window".', $this->config['strategy']));
throw new \LogicException(sprintf('Limiter strategy "%s" does not exists, it must be either "token_bucket", "sliding_window" or "fixed_window".', $this->config['strategy']));
}
}

View File

@ -0,0 +1,125 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
use Symfony\Component\RateLimiter\Exception\InvalidIntervalException;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @experimental in 5.2
*/
final class SlidingWindow implements LimiterStateInterface
{
/**
* @var string
*/
private $id;
/**
* @var int
*/
private $hitCount = 0;
/**
* @var int
*/
private $hitCountForLastWindow = 0;
/**
* @var int how long a time frame is
*/
private $intervalInSeconds;
/**
* @var int the unix timestamp when the current window ends
*/
private $windowEndAt;
/**
* @var bool true if this window has been cached
*/
private $cached = true;
public function __construct(string $id, int $intervalInSeconds)
{
if ($intervalInSeconds < 1) {
throw new InvalidIntervalException(sprintf('The interval must be positive integer, "%d" given.', $intervalInSeconds));
}
$this->id = $id;
$this->intervalInSeconds = $intervalInSeconds;
$this->windowEndAt = time() + $intervalInSeconds;
$this->cached = false;
}
public static function createFromPreviousWindow(self $window, int $intervalInSeconds): self
{
$new = new self($window->id, $intervalInSeconds);
$new->hitCountForLastWindow = $window->hitCount;
$new->windowEndAt = $window->windowEndAt + $intervalInSeconds;
return $new;
}
/**
* @internal
*/
public function __sleep(): array
{
// $cached is not serialized, it should only be set
// upon first creation of the Window.
return ['id', 'hitCount', 'intervalInSeconds', 'hitCountForLastWindow', 'windowEndAt'];
}
public function getId(): string
{
return $this->id;
}
/**
* Store for the rest of this time frame and next.
*/
public function getExpirationTime(): ?int
{
if ($this->cached) {
return null;
}
return 2 * $this->intervalInSeconds;
}
public function isExpired(): bool
{
return time() > $this->windowEndAt;
}
public function add(int $hits = 1)
{
$this->hitCount += $hits;
}
/**
* Calculates the sliding window number of request.
*/
public function getHitCount(): int
{
$startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
$percentOfCurrentTimeFrame = (time() - $startOfWindow) / $this->intervalInSeconds;
return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount);
}
public function getRetryAfter(): \DateTimeImmutable
{
return \DateTimeImmutable::createFromFormat('U', $this->windowEndAt);
}
}

View File

@ -0,0 +1,104 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Lock\NoLock;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use Symfony\Component\RateLimiter\Util\TimeUtil;
/**
* The sliding window algorithm will look at your last window and the current one.
* It is good algorithm to reduce bursts.
*
* Example:
* Last time window we did 8 hits. We are currently 25% into
* the current window. We have made 3 hits in the current window so far.
* That means our sliding window hit count is (75% * 8) + 3 = 9.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @experimental in 5.2
*/
final class SlidingWindowLimiter implements LimiterInterface
{
/**
* @var string
*/
private $id;
/**
* @var int
*/
private $limit;
/**
* @var \DateInterval
*/
private $interval;
/**
* @var StorageInterface
*/
private $storage;
/**
* @var LockInterface|null
*/
private $lock;
use ResetLimiterTrait;
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null)
{
$this->storage = $storage;
$this->lock = $lock ?? new NoLock();
$this->id = $id;
$this->limit = $limit;
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
}
/**
* {@inheritdoc}
*/
public function consume(int $tokens = 1): Limit
{
$this->lock->acquire(true);
try {
$window = $this->storage->fetch($this->id);
if (!$window instanceof SlidingWindow) {
$window = new SlidingWindow($this->id, $this->interval);
} elseif ($window->isExpired()) {
$window = SlidingWindow::createFromPreviousWindow($window, $this->interval);
}
$hitCount = $window->getHitCount();
$availableTokens = $this->getAvailableTokens($hitCount);
if ($availableTokens < $tokens) {
return new Limit($availableTokens, $window->getRetryAfter(), false);
}
$window->add($tokens);
$this->storage->save($window);
return new Limit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true);
} finally {
$this->lock->release();
}
}
private function getAvailableTokens(int $hitCount): int
{
return $this->limit - $hitCount;
}
}

View File

@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\ClockMock;
use Symfony\Component\RateLimiter\SlidingWindowLimiter;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
/**
* @group time-sensitive
*/
class SlidingWindowLimiterTest extends TestCase
{
private $storage;
protected function setUp(): void
{
$this->storage = new InMemoryStorage();
ClockMock::register(InMemoryStorage::class);
}
public function testConsume()
{
$limiter = $this->createLimiter();
$limiter->consume(8);
sleep(15);
$limit = $limiter->consume();
$this->assertTrue($limit->isAccepted());
// We are 25% into the new window
$limit = $limiter->consume(5);
$this->assertFalse($limit->isAccepted());
$this->assertEquals(3, $limit->getRemainingTokens());
sleep(13);
$limit = $limiter->consume(10);
$this->assertTrue($limit->isAccepted());
}
private function createLimiter(): SlidingWindowLimiter
{
return new SlidingWindowLimiter('test', 10, new \DateInterval('PT12S'), $this->storage);
}
}

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\RateLimiter\Exception\InvalidIntervalException;
use Symfony\Component\RateLimiter\SlidingWindow;
class SlidingWindowTest extends TestCase
{
public function testGetExpirationTime()
{
$window = new SlidingWindow('foo', 10);
$this->assertSame(2 * 10, $window->getExpirationTime());
$this->assertSame(2 * 10, $window->getExpirationTime());
$data = serialize($window);
$cachedWindow = unserialize($data);
$this->assertNull($cachedWindow->getExpirationTime());
$new = SlidingWindow::createFromPreviousWindow($cachedWindow, 15);
$this->assertSame(2 * 15, $new->getExpirationTime());
}
public function testInvalidInterval()
{
$this->expectException(InvalidIntervalException::class);
new SlidingWindow('foo', 0);
}
}