Skip to content

Commit e990cb0

Browse files
committed
Add reserve() to the LimiterInterfaces
See symfony/symfony-docs#14370 (comment)
1 parent 96181d9 commit e990cb0

11 files changed

+145
-48
lines changed

src/Symfony/Component/RateLimiter/CompoundLimiter.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Component\RateLimiter;
1313

14+
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
15+
1416
/**
1517
* @author Wouter de Jong <[email protected]>
1618
*
@@ -31,6 +33,11 @@ public function __construct(array $limiters)
3133
$this->limiters = $limiters;
3234
}
3335

36+
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
37+
{
38+
// TODO: Implement reserve() method.
39+
}
40+
3441
public function consume(int $tokens = 1): Limit
3542
{
3643
$minimalLimit = null;

src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,27 @@
1111

1212
namespace Symfony\Component\RateLimiter\Exception;
1313

14+
use Symfony\Component\RateLimiter\Limit;
15+
use Throwable;
16+
1417
/**
1518
* @author Wouter de Jong <[email protected]>
1619
*
1720
* @experimental in 5.2
1821
*/
1922
class MaxWaitDurationExceededException extends \RuntimeException
2023
{
24+
private $limit;
25+
26+
public function __construct(string $message, Limit $limit, int $code = 0, Throwable $previous = null)
27+
{
28+
parent::__construct($message, $code, $previous);
29+
30+
$this->limit = $limit;
31+
}
32+
33+
public function getLimit(): Limit
34+
{
35+
return $this->limit;
36+
}
2137
}

src/Symfony/Component/RateLimiter/FixedWindowLimiter.php

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

1414
use Symfony\Component\Lock\LockInterface;
1515
use Symfony\Component\Lock\NoLock;
16+
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1617
use Symfony\Component\RateLimiter\Storage\StorageInterface;
1718
use Symfony\Component\RateLimiter\Util\TimeUtil;
1819

@@ -40,42 +41,64 @@ public function __construct(string $id, int $limit, \DateInterval $interval, Sto
4041
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
4142
}
4243

43-
/**
44-
* {@inheritdoc}
45-
*/
46-
public function consume(int $tokens = 1): Limit
44+
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
4745
{
46+
if ($tokens > $this->limit) {
47+
throw new \InvalidArgumentException(sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit));
48+
}
49+
4850
$this->lock->acquire(true);
4951

5052
try {
5153
$window = $this->storage->fetch($this->id);
5254
if (!$window instanceof Window) {
53-
$window = new Window($this->id, $this->interval);
55+
$window = new Window($this->id, $this->interval, $this->limit);
5456
}
5557

56-
$hitCount = $window->getHitCount();
57-
$availableTokens = $this->getAvailableTokens($hitCount);
58-
$windowStart = \DateTimeImmutable::createFromFormat('U', time());
59-
if ($availableTokens < $tokens) {
60-
return new Limit($availableTokens, $this->getRetryAfter($windowStart), false);
61-
}
58+
$now = microtime(true);
59+
$availableTokens = $window->getAvailableTokens($now);
60+
if ($availableTokens >= $tokens) {
61+
$window->add($tokens);
6262

63-
$window->add($tokens);
64-
$this->storage->save($window);
63+
$reservation = new Reservation($now, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true));
64+
} else {
65+
$remainingTokens = $tokens - $availableTokens;
66+
$waitDuration = $window->calculateTimeForTokens($remainingTokens);
67+
68+
if (null !== $maxTime && $waitDuration > $maxTime) {
69+
// process needs to wait longer than set interval
70+
throw new MaxWaitDurationExceededException(
71+
sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime),
72+
new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false)
73+
);
74+
}
6575

66-
return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true);
76+
$window->add($tokens);
77+
78+
$reservation = new Reservation($now + $waitDuration, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
79+
}
80+
$this->storage->save($window);
6781
} finally {
6882
$this->lock->release();
6983
}
84+
85+
return $reservation;
7086
}
7187

72-
public function getAvailableTokens(int $hitCount): int
88+
/**
89+
* {@inheritdoc}
90+
*/
91+
public function consume(int $tokens = 1): Limit
7392
{
74-
return $this->limit - $hitCount;
93+
try {
94+
return $this->reserve($tokens, 0)->getLimit();
95+
} catch (MaxWaitDurationExceededException $e) {
96+
return $e->getLimit();
97+
}
7598
}
7699

77-
private function getRetryAfter(\DateTimeImmutable $windowStart): \DateTimeImmutable
100+
public function getAvailableTokens(int $hitCount): int
78101
{
79-
return $windowStart->add(new \DateInterval(sprintf('PT%sS', $this->interval)));
102+
return $this->limit - $hitCount;
80103
}
81104
}

src/Symfony/Component/RateLimiter/LimiterInterface.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,30 @@
1111

1212
namespace Symfony\Component\RateLimiter;
1313

14+
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
15+
1416
/**
1517
* @author Wouter de Jong <[email protected]>
1618
*
1719
* @experimental in 5.2
1820
*/
1921
interface LimiterInterface
2022
{
23+
/**
24+
* Waits until the required number of tokens is available.
25+
*
26+
* The reserved tokens will be taken into account when calculating
27+
* future token consumptions. Do not use this method if you intend
28+
* to skip this process.
29+
*
30+
* @param int $tokens the number of tokens required
31+
* @param float $maxTime maximum accepted waiting time in seconds
32+
*
33+
* @throws MaxWaitDurationExceededException if $maxTime is set and the process needs to wait longer than its value (in seconds)
34+
* @throws \InvalidArgumentException if $tokens is larger than the maximum burst size
35+
*/
36+
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation;
37+
2138
/**
2239
* Use this method if you intend to drop if the required number
2340
* of tokens is unavailable.

src/Symfony/Component/RateLimiter/Reservation.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919
final class Reservation
2020
{
2121
private $timeToAct;
22+
private $limit;
2223

2324
/**
2425
* @param float $timeToAct Unix timestamp in seconds when this reservation should act
2526
*/
26-
public function __construct(float $timeToAct)
27+
public function __construct(float $timeToAct, Limit $limit)
2728
{
2829
$this->timeToAct = $timeToAct;
30+
$this->limit = $limit;
2931
}
3032

3133
public function getTimeToAct(): float
@@ -38,6 +40,11 @@ public function getWaitDuration(): float
3840
return max(0, (-microtime(true)) + $this->timeToAct);
3941
}
4042

43+
public function getLimit(): Limit
44+
{
45+
return $this->limit;
46+
}
47+
4148
public function wait(): void
4249
{
4350
usleep($this->getWaitDuration() * 1e6);

src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,14 @@ public function testConsume()
3838
$limiter3 = $this->createLimiter(12, new \DateInterval('PT30S'));
3939
$limiter = new CompoundLimiter([$limiter1, $limiter2, $limiter3]);
4040

41-
// Reach limiter 1 limit, verify that limiter2 available tokens reduced by 5 and fetch successfully limiter 1
42-
$this->assertEquals(3, $limiter->consume(5)->getRemainingTokens(), 'Limiter 1 reached the limit');
41+
$this->assertEquals(0, $limiter->consume(4)->getRemainingTokens(), 'Limiter 1 reached the limit');
4342
sleep(1); // reset limiter1's window
44-
$this->assertTrue($limiter->consume(2)->isAccepted());
43+
$this->assertTrue($limiter->consume(3)->isAccepted());
4544

46-
// Reach limiter 2 limit, verify that limiter2 available tokens reduced by 5 and and fetch successfully
4745
$this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 2 has no remaining tokens left');
48-
sleep(9); // reset limiter2's window
46+
sleep(10); // reset limiter2's window
4947
$this->assertTrue($limiter->consume(3)->isAccepted());
5048

51-
// Reach limiter 3 limit, verify that limiter2 available tokens reduced by 5 and fetch successfully
5249
$this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 3 reached the limit');
5350
sleep(20); // reset limiter3's window
5451
$this->assertTrue($limiter->consume()->isAccepted());

src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function testConsumeOutsideInterval()
6060
sleep(10);
6161
$limit = $limiter->consume(10);
6262
$this->assertEquals(0, $limit->getRemainingTokens());
63-
$this->assertEquals(time() + 60, $limit->getRetryAfter()->getTimestamp());
63+
$this->assertTrue($limit->isAccepted());
6464
}
6565

6666
public function testWrongWindowFromCache()

src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,22 @@ protected function setUp(): void
3131
public function testSave()
3232
{
3333
$cacheItem = $this->createMock(CacheItemInterface::class);
34-
$cacheItem->expects($this->once())->method('expiresAfter')->with(10);
34+
$cacheItem->expects($this->exactly(2))->method('expiresAfter')->with(10);
3535

3636
$this->pool->expects($this->any())->method('getItem')->with(sha1('test'))->willReturn($cacheItem);
3737
$this->pool->expects($this->exactly(2))->method('save')->with($cacheItem);
3838

39-
$window = new Window('test', 10);
39+
$window = new Window('test', 10, 20);
4040
$this->storage->save($window);
4141

42-
// test that expiresAfter is only called when getExpirationAt() does not return null
4342
$window = unserialize(serialize($window));
4443
$this->storage->save($window);
4544
}
4645

4746
public function testFetchExistingState()
4847
{
4948
$cacheItem = $this->createMock(CacheItemInterface::class);
50-
$window = new Window('test', 10);
49+
$window = new Window('test', 10, 20);
5150
$cacheItem->expects($this->any())->method('get')->willReturn($window);
5251
$cacheItem->expects($this->any())->method('isHit')->willReturn(true);
5352

src/Symfony/Component/RateLimiter/TokenBucket.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
/**
1515
* @author Wouter de Jong <[email protected]>
1616
*
17+
* @internal
1718
* @experimental in 5.2
1819
*/
1920
final class TokenBucket implements LimiterStateInterface

src/Symfony/Component/RateLimiter/TokenBucketLimiter.php

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,22 +74,24 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
7474
$bucket->setTokens($availableTokens - $tokens);
7575
$bucket->setTimer($now);
7676

77-
$reservation = new Reservation($now);
77+
$reservation = new Reservation($now, new Limit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true));
7878
} else {
7979
$remainingTokens = $tokens - $availableTokens;
8080
$waitDuration = $this->rate->calculateTimeForTokens($remainingTokens);
8181

8282
if (null !== $maxTime && $waitDuration > $maxTime) {
8383
// process needs to wait longer than set interval
84-
throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime));
84+
$limit = new Limit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false);
85+
86+
throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $limit);
8587
}
8688

8789
// at $now + $waitDuration all tokens will be reserved for this process,
8890
// so no tokens are left for other processes.
8991
$bucket->setTokens(0);
9092
$bucket->setTimer($now + $waitDuration);
9193

92-
$reservation = new Reservation($bucket->getTimer());
94+
$reservation = new Reservation($bucket->getTimer(), new Limit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
9395
}
9496

9597
$this->storage->save($bucket);
@@ -105,18 +107,10 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
105107
*/
106108
public function consume(int $tokens = 1): Limit
107109
{
108-
$bucket = $this->storage->fetch($this->id);
109-
if (!$bucket instanceof TokenBucket) {
110-
$bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate);
111-
}
112-
$now = microtime(true);
113-
114110
try {
115-
$this->reserve($tokens, 0);
116-
117-
return new Limit($bucket->getAvailableTokens($now) - $tokens, $this->rate->calculateNextTokenAvailability(), true);
111+
return $this->reserve($tokens, 0)->getLimit();
118112
} catch (MaxWaitDurationExceededException $e) {
119-
return new Limit($bucket->getAvailableTokens($now), $this->rate->calculateNextTokenAvailability(), false);
113+
return $e->getLimit();
120114
}
121115
}
122116
}

src/Symfony/Component/RateLimiter/Window.php

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,23 @@
1414
/**
1515
* @author Wouter de Jong <[email protected]>
1616
*
17+
* @internal
1718
* @experimental in 5.2
1819
*/
1920
final class Window implements LimiterStateInterface
2021
{
2122
private $id;
2223
private $hitCount = 0;
2324
private $intervalInSeconds;
25+
private $maxSize;
26+
private $timer;
2427

25-
public function __construct(string $id, int $intervalInSeconds)
28+
public function __construct(string $id, int $intervalInSeconds, int $windowSize, ?float $timer = null)
2629
{
2730
$this->id = $id;
2831
$this->intervalInSeconds = $intervalInSeconds;
32+
$this->maxSize = $windowSize;
33+
$this->timer = $timer ?? microtime(true);
2934
}
3035

3136
public function getId(): string
@@ -38,8 +43,15 @@ public function getExpirationTime(): ?int
3843
return $this->intervalInSeconds;
3944
}
4045

41-
public function add(int $hits = 1)
46+
public function add(int $hits = 1, ?float $now = null)
4247
{
48+
$now = $now ?? microtime(true);
49+
if (($now - $this->timer) > $this->intervalInSeconds) {
50+
// reset window
51+
$this->timer = $now;
52+
$this->hitCount = 0;
53+
}
54+
4355
$this->hitCount += $hits;
4456
}
4557

@@ -48,13 +60,37 @@ public function getHitCount(): int
4860
return $this->hitCount;
4961
}
5062

63+
public function getAvailableTokens(float $now)
64+
{
65+
// if timer is in future, there are no tokens available anymore
66+
if ($this->timer > $now) {
67+
return 0;
68+
}
69+
70+
// if now is more than the window interval in the past, all tokens are available
71+
if (($now - $this->timer) > $this->intervalInSeconds) {
72+
return $this->maxSize;
73+
}
74+
75+
return $this->maxSize - $this->hitCount;
76+
}
77+
78+
public function calculateTimeForTokens(int $tokens): int
79+
{
80+
if (($this->maxSize - $this->hitCount) >= $tokens) {
81+
return 0;
82+
}
83+
84+
$cyclesRequired = ceil($tokens / $this->maxSize);
85+
86+
return $cyclesRequired * $this->intervalInSeconds;
87+
}
88+
5189
/**
5290
* @internal
5391
*/
5492
public function __sleep(): array
5593
{
56-
// $intervalInSeconds is not serialized, it should only be set
57-
// upon first creation of the Window.
58-
return ['id', 'hitCount'];
94+
return ['id', 'hitCount', 'intervalInSeconds', 'timer', 'maxSize'];
5995
}
6096
}

0 commit comments

Comments
 (0)