From c6d3b703153947f7bb6879f63b3fea6f61ad2514 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Wed, 14 Oct 2020 11:18:30 +0200 Subject: [PATCH] [RateLimiter] Adding SlidingWindow algorithm --- .../DependencyInjection/Configuration.php | 4 +- .../Exception/InvalidIntervalException.php | 21 +++ src/Symfony/Component/RateLimiter/Limiter.php | 5 +- .../Component/RateLimiter/SlidingWindow.php | 125 ++++++++++++++++++ .../RateLimiter/SlidingWindowLimiter.php | 104 +++++++++++++++ .../Tests/SlidingWindowLimiterTest.php | 57 ++++++++ .../RateLimiter/Tests/SlidingWindowTest.php | 39 ++++++ 7 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php create mode 100644 src/Symfony/Component/RateLimiter/SlidingWindow.php create mode 100644 src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php create mode 100644 src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php create mode 100644 src/Symfony/Component/RateLimiter/Tests/SlidingWindowTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 0d4ee4f797..179bc24b51 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -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"') diff --git a/src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php b/src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php new file mode 100644 index 0000000000..02b5c810e8 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Exception/InvalidIntervalException.php @@ -0,0 +1,21 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +class InvalidIntervalException extends \LogicException +{ +} diff --git a/src/Symfony/Component/RateLimiter/Limiter.php b/src/Symfony/Component/RateLimiter/Limiter.php index 3898e89018..61018ab807 100644 --- a/src/Symfony/Component/RateLimiter/Limiter.php +++ b/src/Symfony/Component/RateLimiter/Limiter.php @@ -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'])); } } diff --git a/src/Symfony/Component/RateLimiter/SlidingWindow.php b/src/Symfony/Component/RateLimiter/SlidingWindow.php new file mode 100644 index 0000000000..73359dc41b --- /dev/null +++ b/src/Symfony/Component/RateLimiter/SlidingWindow.php @@ -0,0 +1,125 @@ + + * + * 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 + * + * @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); + } +} diff --git a/src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php b/src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php new file mode 100644 index 0000000000..fbc6a41958 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php @@ -0,0 +1,104 @@ + + * + * 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 + * + * @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; + } +} diff --git a/src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php new file mode 100644 index 0000000000..589d4e5550 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php @@ -0,0 +1,57 @@ + + * + * 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); + } +} diff --git a/src/Symfony/Component/RateLimiter/Tests/SlidingWindowTest.php b/src/Symfony/Component/RateLimiter/Tests/SlidingWindowTest.php new file mode 100644 index 0000000000..0bdeb499c5 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/SlidingWindowTest.php @@ -0,0 +1,39 @@ + + * + * 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); + } +}