8000 [RateLimiter] Added reserve() to LimiterInterface and rename Limiter … · symfony/symfony@cd34f21 · GitHub
[go: up one dir, main page]

Skip to content

Commit cd34f21

Browse files
wouterjfabpot
authored andcommitted
[RateLimiter] Added reserve() to LimiterInterface and rename Limiter to RateLimiter
1 parent b3a1851 commit cd34f21

23 files changed

+219
-64
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@
128128
use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface;
129129
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
130130
use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface;
131-
use Symfony\Component\RateLimiter\Limiter;
132131
use Symfony\Component\RateLimiter\LimiterInterface;
132+
use Symfony\Component\RateLimiter\RateLimiter;
133133
use Symfony\Component\RateLimiter\Storage\CacheStorage;
134134
use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader;
135135
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
@@ -2266,7 +2266,7 @@ public static function registerRateLimiter(ContainerBuilder $container, string $
22662266
$limiterConfig['id'] = $name;
22672267
$limiter->replaceArgument(0, $limiterConfig);
22682268

2269-
$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
2269+
$container->registerAliasForArgument($limiterId, RateLimiter::class, $name.'.limiter');
22702270
}
22712271

22722272
private function resolveTrustedHeaders(array $headers): int

src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php

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

1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

14-
use Symfony\Component\RateLimiter\Limiter;
14+
use Symfony\Component\RateLimiter\RateLimiter;
1515

1616
return static function (ContainerConfigurator $container) {
1717
$container->services()
1818
->set('cache.rate_limiter')
1919
->parent('cache.app')
2020
->tag('cache.pool')
2121

22-
->set('limiter', Limiter::class)
22+
->set('limiter', RateLimiter::class)
2323
->abstract()
2424
->args([
2525
abstract_arg('config'),

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use Symfony\Component\DependencyInjection\ContainerBuilder;
1919
use Symfony\Component\DependencyInjection\Reference;
2020
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
21-
use Symfony\Component\RateLimiter\Limiter;
21+
use Symfony\Component\RateLimiter\RateLimiter;
2222
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
2323
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
2424

@@ -63,7 +63,7 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
6363
throw new \LogicException('Login throttling requires symfony/security-http:^5.2.');
6464
}
6565

66-
if (!class_exists(Limiter::class)) {
66+
if (!class_exists(RateLimiter::class)) {
6767
throw new \LogicException('Login throttling requires symfony/rate-limiter to be installed and enabled.');
6868
}
6969

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: 42 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

@@ -33,49 +34,72 @@ final class FixedWindowLimiter implements LimiterInterface
3334

3435
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null)
3536
{
37+
if ($limit < 1) {
38+
throw new \InvalidArgumentException(sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__));
39+
}
40+
3641
$this->storage = $storage;
3742
$this->lock = $lock ?? new NoLock();
3843
$this->id = $id;
3944
$this->limit = $limit;
4045
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
4146
}
4247

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

5056
try {
5157
$window = $this->storage->fetch($this->id);
5258
if (!$window instanceof Window) {
53-
$window = new Window($this->id, $this->interval);
59+
$window = new Window($this->id, $this->interval, $this->limit);
5460
}
5561

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-
}
62+
$now = microtime(true);
63+
$availableTokens = $window->getAvailableTokens($now);
64+
if ($availableTokens >= $tokens) {
65+
$window->add($tokens);
6266

63-
$window->add($tokens);
64-
$this->storage->save($window);
67+
$reservation = new Reservation($now, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true));
68+
} else {
69+
$remainingTokens = $tokens - $availableTokens;
70+
$waitDuration = $window->calculateTimeForTokens($remainingTokens);
6571

66-
return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true);
72+
if (null !== $maxTime && $waitDuration > $maxTime) {
73+
// process needs to wait longer than set interval
74+
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));
75+
}
76+
77+
$window->add($tokens);
78+
79+
$reservation = new Reservation($now + $waitDuration, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
80+
}
81+
$this->storage->save($window);
6782
} finally {
6883
$this->lock->release();
6984
}
85+
86+
return $reservation;
7087
}
7188

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

77-
private function getRetryAfter(\DateTimeImmutable $windowStart): \DateTimeImmutable
101+
public function getAvailableTokens(int $hitCount): int
78102
{
79-
return $windowStart->add(new \DateInterval(sprintf('PT%sS', $this->interval)));
103+
return $this->limit - $hitCount;
80104
}
81105
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
*/
2424
final class NoLimiter implements LimiterInterface
2525
{
26+
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
27+
{
28+
return new Reservation(time(), new Limit(\INF, new \DateTimeImmutable(), true));
29+
}
30+
2631
public function consume(int $tokens = 1): Limit
2732
{
2833
return new Limit(\INF, new \DateTimeImmutable(), true);

src/Symfony/Component/RateLimiter/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ $ composer require symfony/rate-limiter
1818

1919
```php
2020
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
21-
use Symfony\Component\RateLimiter\Limiter;
21+
use Symfony\Component\RateLimiter\RateLimiter;
2222

23-
$limiter = new Limiter([
23+
$limiter = new RateLimiter([
2424
'id' => 'login',
2525
'strategy' => 'token_bucket', // or 'fixed_window'
2626
'limit' => 10,

src/Symfony/Component/RateLimiter/Limiter.php renamed to src/Symfony/Component/RateLimiter/RateLimiter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*
2323
* @experimental in 5.2
2424
*/
25-
final class Limiter
25+
final class RateLimiter
2626
{
2727
private $config;
2828
private $storage;

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()

0 commit comments

Comments
 (0)
0