Skip to content

Commit ca7ff0a

Browse files
committed
calculateTimeForTokens for SlidingWindow
1 parent 521d210 commit ca7ff0a

File tree

3 files changed

+58
-15
lines changed

3 files changed

+58
-15
lines changed

src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,27 @@ public function getRetryAfter(): \DateTimeImmutable
8989
return \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', $this->windowEndAt));
9090
}
9191

92+
public function calculateTimeForTokens(int $maxSize, int $tokens): int
93+
{
94+
$remaining = $maxSize - $this->getHitCount();
95+
if ($remaining >= $tokens) {
96+
return 0;
97+
}
98+
99+
$startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
100+
$percentOfCurrentTimeFrame = min((microtime(true) - $startOfWindow) / $this->intervalInSeconds, 1);
101+
$releasable = $maxSize - floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame));
102+
$remainingWindow = (microtime(true) - $startOfWindow) - $this->intervalInSeconds;
103+
$timePerToken = $remainingWindow / $releasable;
104+
$needed = $tokens - $remaining;
105+
106+
if ($releasable <= $needed) {
107+
return (int) ceil($needed * $timePerToken);
108+
}
109+
110+
return (int) ($this->windowEndAt - microtime(true)) + ceil(($needed - $releasable) * ($this->intervalInSeconds / $maxSize));
111+
}
112+
92113
public function __serialize(): array
93114
{
94115
return [

src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
namespace Symfony\Component\RateLimiter\Policy;
1313

1414
use Symfony\Component\Lock\LockInterface;
15-
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
1615
use Symfony\Component\RateLimiter\LimiterInterface;
16+
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1717
use Symfony\Component\RateLimiter\RateLimit;
1818
use Symfony\Component\RateLimiter\Reservation;
1919
use Symfony\Component\RateLimiter\Storage\StorageInterface;
@@ -48,11 +48,10 @@ public function __construct(string $id, int $limit, \DateInterval $interval, Sto
4848

4949
public function reserve(int $tokens = 1, float $maxTime = null): Reservation
5050
{
51-
throw new ReserveNotSupportedException(__CLASS__);
52-
}
51+
if ($tokens > $this->limit) {
52+
throw new \InvalidArgumentException(sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit));
53+
}
5354

54-
public function consume(int $tokens = 1): RateLimit
55-
{
5655
$this->lock?->acquire(true);
5756

5857
try {
@@ -63,22 +62,43 @@ public function consume(int $tokens = 1): RateLimit
6362
$window = SlidingWindow::createFromPreviousWindow($window, $this->interval);
6463
}
6564

65+
$now = microtime(true);
6666
$hitCount = $window->getHitCount();
6767
$availableTokens = $this->getAvailableTokens($hitCount);
68-
if ($availableTokens < $tokens) {
69-
return new RateLimit($availableTokens, $window->getRetryAfter(), false, $this->limit);
70-
}
68+
if ($availableTokens >= $tokens) {
69+
$window->add($tokens);
7170

72-
$window->add($tokens);
71+
$reservation = new Reservation($now, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit));
72+
} else {
73+
$waitDuration = $window->calculateTimeForTokens($this->limit, max(1, $tokens));
74+
75+
if (null !== $maxTime && $waitDuration > $maxTime) {
76+
// process needs to wait longer than set interval
77+
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));
78+
}
79+
80+
$window->add($tokens);
81+
82+
$reservation = new Reservation($now + $waitDuration, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
83+
}
7384

7485
if (0 < $tokens) {
7586
$this->storage->save($window);
7687
}
77-
78-
return new RateLimit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true, $this->limit);
7988
} finally {
8089
$this->lock?->release();
8190
}
91+
92+
return $reservation;
93+
}
94+
95+
public function consume(int $tokens = 1): RateLimit
96+
{
97+
try {
98+
return $this->reserve($tokens, 0)->getRateLimit();
99+
} catch (MaxWaitDurationExceededException $e) {
100+
return $e->getRateLimit();
101+
}
82102
}
83103

84104
private function getAvailableTokens(int $hitCount): int

src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowLimiterTest.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Bridge\PhpUnit\ClockMock;
16-
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
1716
use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
1817
use Symfony\Component\RateLimiter\RateLimit;
1918
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
@@ -66,14 +65,17 @@ public function testWaitIntervalOnConsumeOverLimit()
6665

6766
$start = microtime(true);
6867
$rateLimit->wait(); // wait 12 seconds
69-
$this->assertEqualsWithDelta($start + 12, microtime(true), 1);
68+
$this->assertEqualsWithDelta($start + (12 / 5), microtime(true), 1);
69+
$this->assertTrue($limiter->consume()->isAccepted());
7070
}
7171

7272
public function testReserve()
7373
{
74-
$this->expectException(ReserveNotSupportedException::class);
74+
$limiter = $this->createLimiter();
75+
$limiter->consume(8);
7576

76-
$this->createLimiter()->reserve();
77+
// 2 over the limit, causing the WaitDuration to become 2/10th of the 12s interval
78+
$this->assertEqualsWithDelta(12 / 5, $limiter->reserve(4)->getWaitDuration(), 1);
7779
}
7880

7981
public function testPeekConsume()

0 commit comments

Comments
 (0)