Skip to content

Commit 04e164f

Browse files
committed
add the ability to use a Clock inside the RateLimiter
1 parent f3a180b commit 04e164f

File tree

11 files changed

+81
-20
lines changed

11 files changed

+81
-20
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,6 +2115,9 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $
21152115
->integerNode('amount')->info('Amount of tokens to add each interval')->defaultValue(1)->end()
21162116
->end()
21172117
->end()
2118+
->booleanNode('use_clock')
2119+
->defaultFalse()
2120+
->end()
21182121
->end()
21192122
->end()
21202123
->end()

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2671,6 +2671,12 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde
26712671
$limiterConfig['id'] = $name;
26722672
$limiter->replaceArgument(0, $limiterConfig);
26732673

2674+
if ($limiterConfig['use_clock']) {
2675+
$limiter->replaceArgument(3, new Reference('clock', ContainerInterface::NULL_ON_INVALID_REFERENCE));
2676+
} else {
2677+
$limiter->replaceArgument(3, null);
2678+
}
2679+
26742680
$container->registerAliasForArgument($limiterId, RateLimiterFactory::class, $name.'.limiter');
26752681
}
26762682
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
abstract_arg('config'),
2626
abstract_arg('storage'),
2727
null,
28+
abstract_arg('clock')
2829
])
2930
;
3031
};

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,7 @@
763763
<xsd:attribute name="strategy" type="xsd:string" />
764764
<xsd:attribute name="limit" type="xsd:int" />
765765
<xsd:attribute name="interval" type="xsd:string" />
766+
<xsd:attribute name="use-clock" type="xsd:boolean" />
766767
</xsd:complexType>
767768

768769
<xsd:complexType name="rate_limiter_rate">

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313

1414
use Symfony\Component\Config\FileLocator;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\ContainerInterface;
1617
use Symfony\Component\DependencyInjection\Exception\LogicException;
1718
use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException;
1819
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
20+
use Symfony\Component\DependencyInjection\Reference;
1921
use Symfony\Component\Workflow\Exception\InvalidDefinitionException;
2022

2123
class PhpFrameworkExtensionTest extends FrameworkExtensionTest
@@ -136,4 +138,36 @@ public function testRateLimiterLockFactory()
136138

137139
$container->getDefinition('limiter.without_lock')->getArgument(2);
138140
}
141+
142+
public function testRateLimiterWithoutClock()
143+
{
144+
$container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
145+
$container->loadFromExtension('framework', [
146+
'http_method_override' => false,
147+
'lock' => true,
148+
'rate_limiter' => [
149+
'without_clock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'],
150+
],
151+
]);
152+
});
153+
154+
$withLock = $container->getDefinition('limiter.without_clock');
155+
$this->assertNull($withLock->getArgument(3));
156+
}
157+
158+
public function testRateLimiterWithClock()
159+
{
160+
$container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
161+
$container->loadFromExtension('framework', [
162+
'http_method_override' => false,
163+
'lock' => true,
164+
'rate_limiter' => [
165+
'without_clock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour', 'use_clock' => true],
166+
],
167+
]);
168+
});
169+
170+
$withLock = $container->getDefinition('limiter.without_clock');
171+
$this->assertEquals(new Reference('clock', ContainerInterface::NULL_ON_INVALID_REFERENCE), $withLock->getArgument(3));
172+
}
139173
}

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

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

1212
namespace Symfony\Component\RateLimiter\Policy;
1313

14+
use Symfony\Component\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockInterface;
1516
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1617
use Symfony\Component\RateLimiter\LimiterInterface;
@@ -29,7 +30,7 @@ final class FixedWindowLimiter implements LimiterInterface
2930
private int $limit;
3031
private int $interval;
3132

32-
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, LockInterface $lock = null)
33+
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, LockInterface $lock = null, private ?ClockInterface $clock = null)
3334
{
3435
if ($limit < 1) {
3536
throw new \InvalidArgumentException(sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__));
@@ -62,18 +63,18 @@ public function reserve(int $tokens = 1, float $maxTime = null): Reservation
6263
if ($availableTokens >= max(1, $tokens)) {
6364
$window->add($tokens, $now);
6465

65-
$reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit));
66+
$reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit, $this->clock));
6667
} else {
6768
$waitDuration = $window->calculateTimeForTokens(max(1, $tokens));
6869

6970
if (null !== $maxTime && $waitDuration > $maxTime) {
7071
// process needs to wait longer than set interval
71-
throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
72+
throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit, $this->clock));
7273
}
7374

7475
$window->add($tokens, $now);
7576

76-
$reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
77+
$reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit, $this->clock));
7778
}
7879

7980
if (0 < $tokens) {

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

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

1212
namespace Symfony\Component\RateLimiter\Policy;
1313

14+
use Symfony\Component\Clock\ClockInterface;
1415
use Symfony\Component\RateLimiter\LimiterInterface;
1516
use Symfony\Component\RateLimiter\RateLimit;
1617
use Symfony\Component\RateLimiter\Reservation;
@@ -25,14 +26,18 @@
2526
*/
2627
final class NoLimiter implements LimiterInterface
2728
{
29+
public function __construct(private ?ClockInterface $clock = null)
30+
{
31+
}
32+
2833
public function reserve(int $tokens = 1, float $maxTime = null): Reservation
2934
{
30-
return new Reservation(microtime(true), new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX));
35+
return new Reservation(microtime(true), new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX, $this->clock));
3136
}
3237

3338
public function consume(int $tokens = 1): RateLimit
3439
{
35-
return new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX);
40+
return new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX, $this->clock);
3641
}
3742

3843
public function reset(): void

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

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

1212
namespace Symfony\Component\RateLimiter\Policy;
1313

14+
use Symfony\Component\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockInterface;
1516
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
1617
use Symfony\Component\RateLimiter\LimiterInterface;
@@ -37,7 +38,7 @@ final class SlidingWindowLimiter implements LimiterInterface
3738
private int $limit;
3839
private int $interval;
3940

40-
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, LockInterface $lock = null)
41+
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, LockInterface $lock = null, private ?ClockInterface $clock = null)
4142
{
4243
$this->storage = $storage;
4344
$this->lock = $lock;
@@ -66,7 +67,7 @@ public function consume(int $tokens = 1): RateLimit
6667
$hitCount = $window->getHitCount();
6768
$availableTokens = $this->getAvailableTokens($hitCount);
6869
if ($availableTokens < $tokens) {
69-
return new RateLimit($availableTokens, $window->getRetryAfter(), false, $this->limit);
70+
return new RateLimit($availableTokens, $window->getRetryAfter(), false, $this->limit, $this->clock);
7071
}
7172

7273
$window->add($tokens);
@@ -75,7 +76,7 @@ public function consume(int $tokens = 1): RateLimit
7576
$this->storage->save($window);
7677
}
7778

78-
return new RateLimit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true, $this->limit);
79+
return new RateLimit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true, $this->limit, $this->clock);
7980
} finally {
8081
$this->lock?->release();
8182
}

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

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

1212
namespace Symfony\Component\RateLimiter\Policy;
1313

14+
use Symfony\Component\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockInterface;
1516
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1617
use Symfony\Component\RateLimiter\LimiterInterface;
@@ -28,7 +29,7 @@ final class TokenBucketLimiter implements LimiterInterface
2829
private int $maxBurst;
2930
private Rate $rate;
3031

31-
public function __construct(string $id, int $maxBurst, Rate $rate, StorageInterface $storage, LockInterface $lock = null)
32+
public function __construct(string $id, int $maxBurst, Rate $rate, StorageInterface $storage, LockInterface $lock = null, private ?ClockInterface $clock = null)
3233
{
3334
$this->id = $id;
3435
$this->maxBurst = $maxBurst;
@@ -72,14 +73,14 @@ public function reserve(int $tokens = 1, float $maxTime = null): Reservation
7273
$bucket->setTokens($availableTokens - $tokens);
7374
$bucket->setTimer($now);
7475

75-
$reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->maxBurst));
76+
$reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->maxBurst, $this->clock));
7677
} else {
7778
$remainingTokens = $tokens - $availableTokens;
7879
$waitDuration = $this->rate->calculateTimeForTokens($remainingTokens);
7980

8081
if (null !== $maxTime && $waitDuration > $maxTime) {
8182
// process needs to wait longer than set interval
82-
$rateLimit = new RateLimit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst);
83+
$rateLimit = new RateLimit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst, $this->clock);
8384

8485
throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $rateLimit);
8586
}
@@ -89,7 +90,7 @@ public function reserve(int $tokens = 1, float $maxTime = null): Reservation
8990
$bucket->setTokens($availableTokens - $tokens);
9091
$bucket->setTimer($now);
9192

92-
$reservation = new Reservation($now + $waitDuration, new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst));
93+
$reservation = new Reservation($now + $waitDuration, new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst, $this->clock));
9394
}
9495

9596
if (0 < $tokens) {

src/Symfony/Component/RateLimiter/RateLimit.php

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

1212
namespace Symfony\Component\RateLimiter;
1313

14+
use Symfony\Component\Clock\ClockInterface;
1415
use Symfony\Component\RateLimiter\Exception\RateLimitExceededException;
1516

1617
/**
@@ -23,7 +24,7 @@ class RateLimit
2324
private bool $accepted;
2425
private int $limit;
2526

26-
public function __construct(int $availableTokens, \DateTimeImmutable $retryAfter, bool $accepted, int $limit)
27+
public function __construct(int $availableTokens, \DateTimeImmutable $retryAfter, bool $accepted, int $limit, private ?ClockInterface $clock = null)
2728
{
2829
$this->availableTokens = $availableTokens;
2930
$this->retryAfter = $retryAfter;
@@ -67,7 +68,13 @@ public function getLimit(): int
6768

6869
public function wait(): void
6970
{
70-
$delta = $this->retryAfter->format('U.u') - microtime(true);
71+
if (null !== $this->clock) {
72+
$now = $this->clock->now()->format('U.u');
73+
} else {
74+
$now = microtime(true);
75+
}
76+
77+
$delta = $this->retryAfter->format('U.u') - $now;
7178
if ($delta <= 0) {
7279
return;
7380
}

src/Symfony/Component/RateLimiter/RateLimiterFactory.php

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

1212
namespace Symfony\Component\RateLimiter;
1313

14+
use Symfony\Component\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockFactory;
1516
use Symfony\Component\OptionsResolver\Options;
1617
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -30,7 +31,7 @@ final class RateLimiterFactory
3031
private StorageInterface $storage;
3132
private ?LockFactory $lockFactory;
3233

33-
public function __construct(array $config, StorageInterface $storage, LockFactory $lockFactory = null)
34+
public function __construct(array $config, StorageInterface $storage, LockFactory $lockFactory = null, private ?ClockInterface $clock = null)
3435
{
3536
$this->storage = $storage;
3637
$this->lockFactory = $lockFactory;
@@ -47,10 +48,10 @@ public function create(string $key = null): LimiterInterface
4748
$lock = $this->lockFactory?->createLock($id);
4849

4950
return match ($this->config['policy']) {
50-
'token_bucket' => new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock),
51-
'fixed_window' => new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock),
52-
'sliding_window' => new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock),
53-
'no_limit' => new NoLimiter(),
51+
'token_bucket' => new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock, $this->clock),
52+
'fixed_window' => new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock, $this->clock),
53+
'sliding_window' => new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock, $this->clock),
54+
'no_limit' => new NoLimiter($this->clock),
5455
default => throw new \LogicException(sprintf('Limiter policy "%s" does not exists, it must be either "token_bucket", "sliding_window", "fixed_window" or "no_limit".', $this->config['policy'])),
5556
};
5657
}

0 commit comments

Comments
 (0)