From 1b4a2df828e7d30d2ee79abf8196bc2c3889d416 Mon Sep 17 00:00:00 2001 From: Jeroeny Date: Mon, 18 Sep 2023 10:04:53 +0200 Subject: [PATCH] [RateLimiter] Add SlidingWindowLimiter::reserve() --- UPGRADE-6.4.md | 5 +++ .../Component/RateLimiter/CHANGELOG.md | 5 +++ .../RateLimiter/Policy/SlidingWindow.php | 29 ++++++++++++- .../Policy/SlidingWindowLimiter.php | 42 ++++++++++++++----- .../Tests/Policy/SlidingWindowLimiterTest.php | 21 +++------- .../Tests/Policy/SlidingWindowTest.php | 18 +++++--- 6 files changed, 86 insertions(+), 34 deletions(-) diff --git a/UPGRADE-6.4.md b/UPGRADE-6.4.md index ddfc9883a5acc..8e8f3daa41b69 100644 --- a/UPGRADE-6.4.md +++ b/UPGRADE-6.4.md @@ -123,6 +123,11 @@ PsrHttpMessageBridge * Remove `ArgumentValueResolverInterface` from `PsrServerRequestResolver` +RateLimiter +----------- + + * Deprecate `SlidingWindow::getRetryAfter`, use `SlidingWindow::calculateTimeForTokens` instead + Routing ------- diff --git a/src/Symfony/Component/RateLimiter/CHANGELOG.md b/src/Symfony/Component/RateLimiter/CHANGELOG.md index adb45e06337c6..dd9ae3153e675 100644 --- a/src/Symfony/Component/RateLimiter/CHANGELOG.md +++ b/src/Symfony/Component/RateLimiter/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.4 +--- + + * Add `SlidingWindowLimiter::reserve()` + 6.2 --- diff --git a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php index eca46e737923a..b0349ec191964 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php @@ -84,9 +84,36 @@ public function getHitCount(): int return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount); } + /** + * @deprecated since Symfony 6.4, use {@see self::calculateTimeForTokens} instead + */ public function getRetryAfter(): \DateTimeImmutable { - return \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', $this->windowEndAt)); + trigger_deprecation('symfony/ratelimiter', '6.4', 'The "%s()" method is deprecated, use "%s::calculateTimeForTokens" instead.', __METHOD__, self::class); + + return \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true) + $this->calculateTimeForTokens(max(1, $this->getHitCount()), 1))); + } + + public function calculateTimeForTokens(int $maxSize, int $tokens): float + { + $remaining = $maxSize - $this->getHitCount(); + if ($remaining >= $tokens) { + return 0; + } + + $time = microtime(true); + $startOfWindow = $this->windowEndAt - $this->intervalInSeconds; + $timePassed = $time - $startOfWindow; + $windowPassed = min($timePassed / $this->intervalInSeconds, 1); + $releasable = max(1, $maxSize - floor($this->hitCountForLastWindow * (1 - $windowPassed))); + $remainingWindow = $this->intervalInSeconds - $timePassed; + $needed = $tokens - $remaining; + + if ($releasable >= $needed) { + return $needed * ($remainingWindow / max(1, $releasable)); + } + + return ($this->windowEndAt - $time) + ($needed - $releasable) * ($this->intervalInSeconds / $maxSize); } public function __serialize(): array diff --git a/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php b/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php index 07b08b2a3ae22..bf62b89ffc7f9 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php @@ -12,7 +12,7 @@ namespace Symfony\Component\RateLimiter\Policy; use Symfony\Component\Lock\LockInterface; -use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; +use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; use Symfony\Component\RateLimiter\Reservation; @@ -48,11 +48,10 @@ public function __construct(string $id, int $limit, \DateInterval $interval, Sto public function reserve(int $tokens = 1, float $maxTime = null): Reservation { - throw new ReserveNotSupportedException(__CLASS__); - } + if ($tokens > $this->limit) { + throw new \InvalidArgumentException(sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit)); + } - public function consume(int $tokens = 1): RateLimit - { $this->lock?->acquire(true); try { @@ -63,22 +62,43 @@ public function consume(int $tokens = 1): RateLimit $window = SlidingWindow::createFromPreviousWindow($window, $this->interval); } + $now = microtime(true); $hitCount = $window->getHitCount(); $availableTokens = $this->getAvailableTokens($hitCount); - if ($availableTokens < $tokens) { - return new RateLimit($availableTokens, $window->getRetryAfter(), false, $this->limit); - } + if ($availableTokens >= $tokens) { + $window->add($tokens); - $window->add($tokens); + $reservation = new Reservation($now, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit)); + } else { + $waitDuration = $window->calculateTimeForTokens($this->limit, max(1, $tokens)); + + if (null !== $maxTime && $waitDuration > $maxTime) { + // process needs to wait longer than set interval + throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit)); + } + + $window->add($tokens); + + $reservation = new Reservation($now + $waitDuration, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit)); + } if (0 < $tokens) { $this->storage->save($window); } - - return new RateLimit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true, $this->limit); } finally { $this->lock?->release(); } + + return $reservation; + } + + public function consume(int $tokens = 1): RateLimit + { + try { + return $this->reserve($tokens, 0)->getRateLimit(); + } catch (MaxWaitDurationExceededException $e) { + return $e->getRateLimit(); + } } private function getAvailableTokens(int $hitCount): int diff --git a/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php index d34bfa44bbe67..21deb69c3932b 100644 --- a/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php @@ -13,7 +13,6 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ClockMock; -use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter; use Symfony\Component\RateLimiter\RateLimit; use Symfony\Component\RateLimiter\Storage\InMemoryStorage; @@ -66,27 +65,17 @@ public function testWaitIntervalOnConsumeOverLimit() $start = microtime(true); $rateLimit->wait(); // wait 12 seconds - $this->assertEqualsWithDelta($start + 12, microtime(true), 1); + $this->assertEqualsWithDelta($start + (12 / 5), microtime(true), 1); + $this->assertTrue($limiter->consume()->isAccepted()); } public function testReserve() - { - $this->expectException(ReserveNotSupportedException::class); - - $this->createLimiter()->reserve(); - } - - public function testPeekConsume() { $limiter = $this->createLimiter(); + $limiter->consume(8); - $limiter->consume(9); - - for ($i = 0; $i < 2; ++$i) { - $rateLimit = $limiter->consume(0); - $this->assertTrue($rateLimit->isAccepted()); - $this->assertSame(10, $rateLimit->getLimit()); - } + // 2 over the limit, causing the WaitDuration to become 2/10th of the 12s interval + $this->assertEqualsWithDelta(12 / 5, $limiter->reserve(4)->getWaitDuration(), 1); } private function createLimiter(): SlidingWindowLimiter diff --git a/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php b/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php index ea4109a7c57e2..737c5566ea44e 100644 --- a/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php @@ -81,12 +81,14 @@ public function testCreateFromPreviousWindowUsesMicrotime() { ClockMock::register(SlidingWindow::class); $window = new SlidingWindow('foo', 8); + $window->add(); usleep(11.6 * 1e6); // wait just under 12s (8+4) $new = SlidingWindow::createFromPreviousWindow($window, 4); + $new->add(); // should be 400ms left (12 - 11.6) - $this->assertEqualsWithDelta(0.4, $new->getRetryAfter()->format('U.u') - microtime(true), 0.2); + $this->assertEqualsWithDelta(0.4, $new->calculateTimeForTokens(1, 1), 0.1); } public function testIsExpiredUsesMicrotime() @@ -101,18 +103,22 @@ public function testIsExpiredUsesMicrotime() public function testGetRetryAfterUsesMicrotime() { $window = new SlidingWindow('foo', 10); + $window->add(); usleep(9.5 * 1e6); // should be 500ms left (10 - 9.5) - $this->assertEqualsWithDelta(0.5, $window->getRetryAfter()->format('U.u') - microtime(true), 0.2); + $this->assertEqualsWithDelta(0.5, $window->calculateTimeForTokens(1, 1), 0.1); } public function testCreateAtExactTime() { - ClockMock::register(SlidingWindow::class); - ClockMock::withClockMock(1234567890.000000); $window = new SlidingWindow('foo', 10); - $window->getRetryAfter(); - $this->assertEquals('1234567900.000000', $window->getRetryAfter()->format('U.u')); + $this->assertEquals(30, $window->calculateTimeForTokens(1, 4)); + + $window = new SlidingWindow('foo', 10); + $window->add(); + $window = SlidingWindow::createFromPreviousWindow($window, 10); + sleep(10); + $this->assertEquals(40, $window->calculateTimeForTokens(1, 4)); } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy