8000 Add SlidingWindowLimiter::reserve() · symfony/symfony@0363e47 · GitHub
[go: up one dir, main page]

Skip to content

Commit 0363e47

Browse files
committed
Add SlidingWindowLimiter::reserve()
1 parent 517128f commit 0363e47

File tree

6 files changed

+84
-34
lines changed

6 files changed

+84
-34
lines changed

UPGRADE-6.4.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ PsrHttpMessageBridge
123123

124124
* Remove `ArgumentValueResolverInterface` from `PsrServerRequestResolver`
125125

126+
RateLimiter
127+
-------
128+
129+
* Deprecate `SlidingWindow::getRetryAfter`, use `SlidingWindow::calculateTimeForTokens` instead
130+
126131
Routing
127132
-------
128133

src/Symfony/Component/RateLimiter/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
6.4
5+
---
6+
7+
* Add `SlidingWindowLimiter::reserve()`
8+
49
6.2
510
---
611

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,34 @@ public function getHitCount(): int
8484
return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount);
8585
}
8686

87+
/**
88+
* @deprecated since Symfony 6.4, use {@see self::calculateTimeForTokens} instead
89+
*/
8790
public function getRetryAfter(): \DateTimeImmutable
8891
{
89-
return \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', $this->windowEndAt));
92+
return \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true) + $this->calculateTimeForTokens(max(1, $this->getHitCount()), 1)));
93+
}
94+
95+
public function calculateTimeForTokens(int $maxSize, int $tokens): float
96+
{
97+
$remaining = $maxSize - $this->getHitCount();
98+
if ($remaining >= $tokens) {
99+
return 0;
100+
}
101+
102+
$time = microtime(true);
103+
$startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
104+
$timePassed = $time - $startOfWindow;
105+
$windowPassed = min($timePassed / $this->intervalInSeconds, 1);
106+
$releasable = max(1, $maxSize - floor($this->hitCountForLastWindow * (1 - $windowPassed)));
107+
$remainingWindow = $this->intervalInSeconds - $timePassed;
108+
$needed = $tokens - $remaining;
109+
110+
if ($releasable >= $needed) {
111+
return $needed * ($remainingWindow / max(1, $releasable));
112+
}
113+
114+
return ($this->windowEndAt - $time) + ($needed - $releasable) * ($this->intervalInSeconds / $maxSize);
90115
}
91116

92117
public function __serialize(): array

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

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

1414
use Symfony\Component\Lock\LockInterface;
15-
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
15+
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1616
use Symfony\Component\RateLimiter\LimiterInterface;
1717
use Symfony\Component\RateLimiter\RateLimit;
1818
use Symfony\Component\RateLimiter\Reservation;
@@ -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: 5 additions & 16 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,27 +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()
73-
{
74-
$this->expectException(ReserveNotSupportedException::class);
75-
76-
$this->createLimiter()->reserve();
77-
}
78-
79-
public function testPeekConsume()
8073
{
8174
$limiter = $this->createLimiter();
75+
$limiter->consume(8);
8276

83-
$limiter->consume(9);
84-
85-
for ($i = 0; $i < 2; ++$i) {
86-
$rateLimit = $limiter->consume(0);
87-
$this->assertTrue($rateLimit->isAccepted());
88-
$this->assertSame(10, $rateLimit->getLimit());
89-
}
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);
9079
}
9180

9281
private function createLimiter(): SlidingWindowLimiter

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,14 @@ public function testCreateFromPreviousWindowUsesMicrotime()
8181
{
8282
ClockMock::register(SlidingWindow::class);
8383
$window = new SlidingWindow('foo', 8);
84+
$window->add();
8485

8586
usleep(11.6 * 1e6); // wait just under 12s (8+4)
8687
$new = SlidingWindow::createFromPreviousWindow($window, 4);
88+
$new->add();
8789

8890
// should be 400ms left (12 - 11.6)
89-
$this->assertEqualsWithDelta(0.4, $new->getRetryAfter()->format('U.u') - microtime(true), 0.2);
91+
$this->assertEqualsWithDelta(0.4, $new->calculateTimeForTokens(1, 1), 0.1);
9092
}
9193

9294
public function testIsExpiredUsesMicrotime()
@@ -101,18 +103,22 @@ public function testIsExpiredUsesMicrotime()
101103
public function testGetRetryAfterUsesMicrotime()
102104
{
103105
$window = new SlidingWindow('foo', 10);
106+
$window->add();
104107

105108
usleep(9.5 * 1e6);
106109
// should be 500ms left (10 - 9.5)
107-
$this->assertEqualsWithDelta(0.5, $window->getRetryAfter()->format('U.u') - microtime(true), 0.2);
110+
$this->assertEqualsWithDelta(0.5, $window->calculateTimeForTokens(1, 1), 0.1);
108111
}
109112

110113
public function testCreateAtExactTime()
111114
{
112-
ClockMock::register(SlidingWindow::class);
113-
ClockMock::withClockMock(1234567890.000000);
114115
$window = new SlidingWindow('foo', 10);
115-
$window->getRetryAfter();
116-
$this->assertEquals('1234567900.000000', $window->getRetryAfter()->format('U.u'));
116+
$this->assertEquals(30, $window->calculateTimeForTokens(1, 4));
117+
118+
$window = new SlidingWindow('foo', 10);
119+
$window->add();
120+
$window = SlidingWindow::createFromPreviousWindow($window, 10);
121+
sl 484A eep(10);
122+
$this->assertEquals(40, $window->calculateTimeForTokens(1, 4));
117123
}
118124
}

0 commit comments

Comments
 (0)
0