Skip to content

Commit 319afde

Browse files
committed
Add reserve() to the LimiterInterfaces
See symfony/symfony-docs#14370 (comment)
1 parent 4c96ab0 commit 319afde

14 files changed

+190
-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\ReserveNotSupportedException;
15+
1416
/**
1517
* @author Wouter de Jong <wouter@wouterj.nl>
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+
throw new ReserveNotSupportedException(__CLASS__);
39+
}
40+
3441
public function consume(int $tokens = 1): Limit
3542
{
3643
$minimalLimit = null;

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

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

1212
namespace Symfony\Component\RateLimiter\Exception;
1313

14+
use Symfony\Component\RateLimiter\Limit;
15+
1416
/**
1517
* @author Wouter de Jong <wouter@wouterj.nl>
1618
*
1719
* @experimental in 5.2
1820
*/
1921
class MaxWaitDurationExceededException extends \RuntimeException
2022
{
23+
private $limit;
24+
25+
public function __construct(string $message, Limit $limit, int $code = 0, ?\Throwable $previous = null)
26+
{
27+
parent::__construct($message, $code, $previous);
28+
29+
$this->limit = $limit;
30+
}
31+
32+
public function getLimit(): Limit
33+
{
34+
return $this->limit;
35+
}
2136
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter\Exception;
13+
14+
/**
15+
* @author Wouter de Jong <wouter@wouterj.nl>
16+
*
17+
* @experimental in 5.2
18+
*/
19+
class ReserveNotSupportedException extends \BadMethodCallException
20+
{
21+
public function __construct(string $limiterClass, int $code = 0, ?\Throwable $previous = null)
22+
{
23+
parent::__construct(sprintf('Reserving tokens is not supported by "%s".', $limiterClass), $code, $previous);
24+
}
25+
}

src/Symfony/Component/RateLimiter/FixedWindowLimiter.php

Lines changed: 38 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,61 @@ 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(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
71+
}
6572

66-
return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true);
73+
$window->add($tokens);
74+
75+
$reservation = new Reservation($now + $waitDuration, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
76+
}
77+
$this->storage->save($window);
6778
} finally {
6879
$this->lock->release();
6980
}
81+
82+
return $reservation;
7083
}
7184

72-
public function getAvailableTokens(int $hitCount): int
85+
/**
86+
* {@inheritdoc}
87+
*/
88+
public function consume(int $tokens = 1): Limit
7389
{
74-
return $this->limit - $hitCount;
90+
try {
91+
return $this->reserve($tokens, 0)->getLimit();
92+
} catch (MaxWaitDurationExceededException $e) {
93+
return $e->getLimit();
94+
}
7595
}
7696

77-
private function getRetryAfter(\DateTimeImmutable $windowStart): \DateTimeImmutable
97+
public function getAvailableTokens(int $hitCount): int
7898
{
79-
return $windowStart->add(new \DateInterval(sprintf('PT%sS', $this->interval)));
99+
return $this->limit - $hitCount;
80100
}
81101
}

src/Symfony/Component/RateLimiter/LimiterInterface.php

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

1212
namespace Symfony\Component\RateLimiter;
1313

14+
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
15+
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
16+
1417
/**
1518
* @author Wouter de Jong <wouter@wouterj.nl>
1619
*
1720
* @experimental in 5.2
1821
*/
1922
interface LimiterInterface
2023
{
24+
/**
25+
* Waits until the required number of tokens is available.
26+
*
27+
* The reserved tokens will be taken into account when calculating
28+
* future token consumptions. Do not use this method if you intend
29+
* to skip this process.
30+
*
31+
* @param int $tokens the number of tokens required
32+
* @param float $maxTime maximum accepted waiting time in seconds
33+
*
34+
* @throws MaxWaitDurationExceededException if $maxTime is set and the process needs to wait longer than its value (in seconds)
35+
* @throws ReserveNotSupportedException if this limiter implementation doesn't support reserving tokens
36+
* @throws \InvalidArgumentException if $tokens is larger than the maximum burst size
37+
*/
38+
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation;
39+
2140
/**
2241
* Use this method if you intend to drop if the required number
2342
* 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/SlidingWindowLimiter.php

Lines changed: 6 additions & 0 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\ReserveNotSupportedException;
1617
use Symfony\Component\RateLimiter\Storage\StorageInterface;
1718
use Symfony\Component\RateLimiter\Util\TimeUtil;
1819

@@ -67,6 +68,11 @@ public function __construct(string $id, int $limit, \DateInterval $interval, Sto
6768
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
6869
}
6970

71+
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
72+
{
73+
throw new ReserveNotSupportedException(__CLASS__);
74+
}
75+
7076
/**
7177
* {@inheritdoc}
7278
*/

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Bridge\PhpUnit\ClockMock;
1616
use Symfony\Component\RateLimiter\CompoundLimiter;
17+
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
1718
use Symfony\Component\RateLimiter\FixedWindowLimiter;
1819
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
1920

@@ -38,22 +39,26 @@ public function testConsume()
3839
$limiter3 = $this->createLimiter(12, new \DateInterval('PT30S'));
3940
$limiter = new CompoundLimiter([$limiter1, $limiter2, $limiter3]);
4041

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');
42+
$this->assertEquals(0, $limiter->consume(4)->getRemainingTokens(), 'Limiter 1 reached the limit');
4343
sleep(1); // reset limiter1's window
44-
$this->assertTrue($limiter->consume(2)->isAccepted());
44+
$this->assertTrue($limiter->consume(3)->isAccepted());
4545

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

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

55+
public function testReserve()
56+
{
57+
$this->expectException(ReserveNotSupportedException::class);
58+
59+
(new CompoundLimiter([$this->createLimiter(4, new \DateInterval('PT1S'))]))->reserve();
60+
}
61+
5762
private function createLimiter(int $limit, \DateInterval $interval): FixedWindowLimiter
5863
{
5964
return new FixedWindowLimiter('test'.$limit, $limit, $interval, $this->storage);

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/SlidingWindowLimiterTest.php

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

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Bridge\PhpUnit\ClockMock;
16+
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
1617
use Symfony\Component\RateLimiter\SlidingWindowLimiter;
1718
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
1819

@@ -50,6 +51,13 @@ public function testConsume()
5051
$this->assertTrue($limit->isAccepted());
5152
}
5253

54+
public function testReserve()
55+
{
56+
$this->expectException(ReserveNotSupportedException::class);
57+
58+
$this->createLimiter()->reserve();
59+
}
60+
5361
private function createLimiter(): SlidingWindowLimiter
5462
{
5563
return new SlidingWindowLimiter('test', 10, new \DateInterval('PT12S'), $this->storage);

0 commit comments

Comments
 (0)