diff --git a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php index e79822801e6bf..67fad5ba18d46 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php @@ -100,9 +100,26 @@ public function getHitCount(): int return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount); } - public function getRetryAfter(): \DateTimeImmutable + public function calculateTimeForTokens(int $maxSize, int $tokens): float { - return \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', $this->windowEndAt)); + $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))); // 2 * (0.7) =1, 3 + $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 0d9d1bca73a94..bec65f02a4ab9 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php @@ -13,7 +13,7 @@ use Symfony\Component\Lock\LockInterface; use Symfony\Component\Lock\NoLock; -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; @@ -53,14 +53,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)); + } - /** - * {@inheritdoc} - */ - public function consume(int $tokens = 1): RateLimit - { $this->lock->acquire(true); try { @@ -71,19 +67,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); + + $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)); - $window->add($tokens); - $this->storage->save($window); + 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)); + } - return new RateLimit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true, $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); + } } 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 84247ffe366c6..f6eab8b7ee492 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,14 +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); + $limiter = $this->createLimiter(); + $limiter->consume(8); - $this->createLimiter()->reserve(); + // 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)); } }