diff --git a/UPGRADE-6.4.md b/UPGRADE-6.4.md index ddfc9883a5ac..8e8f3daa41b6 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 adb45e06337c..dd9ae3153e67 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 eca46e737923..b0349ec19196 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 07b08b2a3ae2..bf62b89ffc7f 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 d34bfa44bbe6..21deb69c3932 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 ea4109a7c57e..737c5566ea44 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