[RateLimiter] Adding SlidingWindow algorithm
This commit is contained in:
parent
8430954bce
commit
c6d3b70315
@ -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"')
|
||||
|
@ -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
|
||||
{
|
||||
}
|
@ -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']));
|
||||
}
|
||||
}
|
||||
|
||||
|
125
src/Symfony/Component/RateLimiter/SlidingWindow.php
Normal file
125
src/Symfony/Component/RateLimiter/SlidingWindow.php
Normal 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);
|
||||
}
|
||||
}
|
104
src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php
Normal file
104
src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user