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)); } }