Skip to content

Commit a7af865

Browse files
committed
add the ability to use a Clock inside the RateLimiter
1 parent d489cfc commit a7af865

File tree

11 files changed

+58
-30
lines changed

11 files changed

+58
-30
lines changed

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+
service('clock')->nullOnInvalid(),
2829
])
2930
;
3031
};

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

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

1212
namespace Symfony\Component\RateLimiter\Policy;
1313

14+
use Psr\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockInterface;
1516
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1617
use Symfony\Component\RateLimiter\LimiterInterface;
@@ -34,6 +35,7 @@ public function __construct(
3435
\DateInterval $interval,
3536
StorageInterface $storage,
3637
?LockInterface $lock = null,
38+
private ?ClockInterface $clock = null,
3739
) {
3840
if ($limit < 1) {
3941
throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__));
@@ -56,10 +58,10 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
5658
try {
5759
$window = $this->storage->fetch($this->id);
5860
if (!$window instanceof Window) {
59-
$window = new Window($this->id, $this->interval, $this->limit);
61+
$window = new Window($this->id, $this->interval, $this->limit, null, $this->clock);
6062
}
6163

62-
$now = microtime(true);
64+
$now = (float) ($this->clock?->now()->format('U.u') ?? microtime(true));
6365
$availableTokens = $window->getAvailableTokens($now);
6466

6567
if (0 === $tokens) {
@@ -68,18 +70,18 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
6870
} elseif ($availableTokens >= $tokens) {
6971
$window->add($tokens, $now);
7072

71-
$reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit));
73+
$reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit, $this->clock));
7274
} else {
7375
$waitDuration = $window->calculateTimeForTokens($tokens, $now);
7476

7577
if (null !== $maxTime && $waitDuration > $maxTime) {
7678
// 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($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
79+
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));
7880
}
7981

8082
$window->add($tokens, $now);
8183

82-
$reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
84+
$reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit, $this->clock));
8385
}
8486

8587
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 Psr\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($this->clock?->now()->format('U.u') ?? 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/SlidingWindow.php

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

1212
namespace Symfony\Component\RateLimiter\Policy;
1313

14+
use Psr\Clock\ClockInterface;
1415
use Symfony\Component\RateLimiter\Exception\InvalidIntervalException;
1516
use Symfony\Component\RateLimiter\LimiterStateInterface;
1617

@@ -28,19 +29,20 @@ final class SlidingWindow implements LimiterStateInterface
2829
public function __construct(
2930
private string $id,
3031
private int $intervalInSeconds,
32+
private ?ClockInterface $clock = null,
3133
) {
3234
if ($intervalInSeconds < 1) {
3335
throw new InvalidIntervalException(\sprintf('The interval must be positive integer, "%d" given.', $intervalInSeconds));
3436
}
35-
$this->windowEndAt = microtime(true) + $intervalInSeconds;
37+
$this->windowEndAt = (float) ($this->clock?->now()->format('U.u') ?? microtime(true)) + $intervalInSeconds;
3638
}
3739

38-
public static function createFromPreviousWindow(self $window, int $intervalInSeconds): self
40+
public static function createFromPreviousWindow(self $window, int $intervalInSeconds, ClockInterface $clock = null): self
3941
{
4042
$new = new self($window->id, $intervalInSeconds);
4143
$windowEndAt = $window->windowEndAt + $intervalInSeconds;
4244

43-
if (microtime(true) < $windowEndAt) {
45+
if (($clock?->now()->format('U.u') ?? microtime(true)) < $windowEndAt) {
4446
$new->hitCountForLastWindow = $window->hitCount;
4547
$new->windowEndAt = $windowEndAt;
4648
}
@@ -58,12 +60,12 @@ public function getId(): string
5860
*/
5961
public function getExpirationTime(): int
6062
{
61-
return (int) ($this->windowEndAt + $this->intervalInSeconds - microtime(true));
63+
return (int) ($this->windowEndAt + $this->intervalInSeconds - ($this->clock?->now()->format('U.u') ?? microtime(true)));
6264
}
6365

6466
public function isExpired(): bool
6567
{
66-
return microtime(true) > $this->windowEndAt;
68+
return ($this->clock?->now()->format('U.u') ?? microtime(true)) > $this->windowEndAt;
6769
}
6870

6971
public function add(int $hits = 1): void
@@ -77,7 +79,7 @@ public function add(int $hits = 1): void
7779
public function getHitCount(): int
7880
{
7981
$startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
80-
$percentOfCurrentTimeFrame = min((microtime(true) - $startOfWindow) / $this->intervalInSeconds, 1);
82+
$percentOfCurrentTimeFrame = min((($this->clock?->now()->format('U.u') ?? microtime(true)) - $startOfWindow) / $this->intervalInSeconds, 1);
8183

8284
return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount);
8385
}

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

Lines changed: 5 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 Psr\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockInterface;
1516
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1617
use Symfony\Component\RateLimiter\LimiterInterface;
@@ -42,6 +43,7 @@ public function __construct(
4243
\DateInterval $interval,
4344
StorageInterface $storage,
4445
?LockInterface $lock = null,
46+
private ?ClockInterface $clock = null,
4547
) {
4648
$this->storage = $storage;
4749
$this->lock = $lock;
@@ -60,9 +62,9 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
6062
try {
6163
$window = $this->storage->fetch($this->id);
6264
if (!$window instanceof SlidingWindow) {
63-
$window = new SlidingWindow($this->id, $this->interval);
65+
$window = new SlidingWindow($this->id, $this->interval, $this->clock);
6466
} elseif ($window->isExpired()) {
65-
$window = SlidingWindow::createFromPreviousWindow($window, $this->interval);
67+
$window = SlidingWindow::createFromPreviousWindow($window, $this->interval, $this->clock);
6668
}
6769

6870
$now = microtime(true);
@@ -72,7 +74,7 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
7274
$resetDuration = $window->calculateTimeForTokens($this->limit, $window->getHitCount());
7375
$resetTime = \DateTimeImmutable::createFromFormat('U', $availableTokens ? floor($now) : floor($now + $resetDuration));
7476

75-
return new Reservation($now, new RateLimit($availableTokens, $resetTime, true, $this->limit));
77+
return new Reservation($now, new RateLimit($availableTokens, $resetTime, true, $this->limit, $this->clock));
7678
}
7779
if ($availableTokens >= $tokens) {
7880
$window->add($tokens);

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

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

1212
namespace Symfony\Component\RateLimiter\Policy;
1313

14+
use Psr\Clock\ClockInterface;
1415
use Symfony\Component\RateLimiter\LimiterStateInterface;
1516

1617
/**
@@ -35,6 +36,7 @@ public function __construct(
3536
int $initialTokens,
3637
private Rate $rate,
3738
?float $timer = null,
39+
?ClockInterface $clock = null,
3840
) {
3941
if ($initialTokens < 1) {
4042
throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', TokenBucketLimiter::class));
@@ -43,7 +45,7 @@ public function __construct(
4345
$this->id = $id;
4446
$this->tokens = $this->burstSize = $initialTokens;
4547
$this->rate = $rate;
46-
$this->timer = $timer ?? microtime(true);
48+
$this->timer = $timer ?? $clock?->now()->format('U.u') ?? microtime(true);
4749
}
4850

4951
public function getId(): string

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

Lines changed: 6 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 Psr\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockInterface;
1516
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
1617
use Symfony\Component\RateLimiter\LimiterInterface;
@@ -31,6 +32,7 @@ public function __construct(
3132
private Rate $rate,
3233
StorageInterface $storage,
3334
?LockInterface $lock = null,
35+
private ?ClockInterface $clock = null,
3436
) {
3537
$this->id = $id;
3638
$this->storage = $storage;
@@ -64,7 +66,7 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
6466
$bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate);
6567
}
6668

67-
$now = microtime(true);
69+
$now = (float) ($this->clock?->now()->format('U.u') ?? microtime(true));
6870
$availableTokens = $bucket->getAvailableTokens($now);
6971

7072
if ($availableTokens >= $tokens) {
@@ -80,14 +82,14 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
8082
$waitTime = \DateTimeImmutable::createFromFormat('U', floor($now));
8183
}
8284

83-
$reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), $waitTime, true, $this->maxBurst));
85+
$reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), $waitTime, true, $this->maxBurst, $this->clock));
8486
} else {
8587
$remainingTokens = $tokens - $availableTokens;
8688
$waitDuration = $this->rate->calculateTimeForTokens($remainingTokens);
8789

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

9294
throw new MaxWaitDurationExceededException(\sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $rateLimit);
9395
}
@@ -96,7 +98,7 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
9698
// so no tokens are left for other processes.
9799
$bucket->setTokens($availableTokens - $tokens);
98100

99-
$reservation = new Reservation($now + $waitDuration, new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst));
101+
$reservation = new Reservation($now + $waitDuration, new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst, $this->clock));
100102
}
101103

102104
if (0 < $tokens) {

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

Lines changed: 4 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 Psr\Clock\ClockInterface;
1415
use Symfony\Component\RateLimiter\LimiterStateInterface;
1516

1617
/**
@@ -29,9 +30,10 @@ public function __construct(
2930
private int $intervalInSeconds,
3031
int $windowSize,
3132
?float $timer = null,
33+
private ?ClockInterface $clock = null,
3234
) {
3335
$this->maxSize = $windowSize;
34-
$this->timer = $timer ?? microtime(true);
36+
$this->timer = $timer ?? $this->clock?->now()->format('U.u') ?? microtime(true);
3537
}
3638

3739
public function getId(): string
@@ -46,7 +48,7 @@ public function getExpirationTime(): ?int
4648

4749
public function add(int $hits = 1, ?float $now = null): void
4850
{
49-
$now ??= microtime(true);
51+
$now ??= $this->clock?->now()->format('U.u') ?? microtime(true);
5052
if (($now - $this->timer) > $this->intervalInSeconds) {
5153
// reset window
5254
$this->timer = $now;

src/Symfony/Component/RateLimiter/RateLimit.php

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

1212
namespace Symfony\Component\RateLimiter;
1313

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

1617
/**
@@ -23,6 +24,7 @@ public function __construct(
2324
private \DateTimeImmutable $retryAfter,
2425
private bool $accepted,
2526
private int $limit,
27+
private ?ClockInterface $clock = null,
2628
) {
2729
}
2830

@@ -62,7 +64,8 @@ public function getLimit(): int
6264

6365
public function wait(): void
6466
{
65-
$delta = $this->retryAfter->format('U.u') - microtime(true);
67+
$now = (float) ($this->clock?->now()->format('U.u') ?? microtime(true));
68+
$delta = $this->retryAfter->format('U.u') - $now;
6669
if ($delta <= 0) {
6770
return;
6871
}

src/Symfony/Component/RateLimiter/RateLimiterFactory.php

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

1212
namespace Symfony\Component\RateLimiter;
1313

14+
use Psr\Clock\ClockInterface;
1415
use Symfony\Component\Lock\LockFactory;
1516
use Symfony\Component\OptionsResolver\Options;
1617
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -32,6 +33,7 @@ public function __construct(
3233
array $config,
3334
private StorageInterface $storage,
3435
private ?LockFactory $lockFactory = null,
36+
private ?ClockInterface $clock = null,
3537
) {
3638
$options = new OptionsResolver();
3739
self::configureOptions($options);
@@ -45,10 +47,10 @@ public function create(?string $key = null): LimiterInterface
4547
$lock = $this->lockFactory?->createLock($id);
4648

4749
return match ($this->config['policy']) {
48-
'token_bucket' => new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock),
49-
'fixed_window' => new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock),
50-
'sliding_window' => new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock),
51-
'no_limit' => new NoLimiter(),
50+
'token_bucket' => new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock, $this->clock),
51+
'fixed_window' => new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock, $this->clock),
52+
'sliding_window' => new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock, $this->clock),
53+
'no_limit' => new NoLimiter($this->clock),
5254
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'])),
5355
};
5456
}

src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.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\Storage;
1313

14+
use Psr\Clock\ClockInterface;
1415
use Symfony\Component\RateLimiter\LimiterStateInterface;
1516

1617
/**
@@ -20,6 +21,10 @@ class InMemoryStorage implements StorageInterface
2021
{
2122
private array $buckets = [];
2223

24+
public function __construct(private ?ClockInterface $clock = null)
25+
{
26+
}
27+
2328
public function save(LimiterStateInterface $limiterState): void
2429
{
2530
$this->buckets[$limiterState->getId()] = [$this->getExpireAt($limiterState), serialize($limiterState)];
@@ -32,7 +37,7 @@ public function fetch(string $limiterStateId): ?LimiterStateInterface
3237
}
3338

3439
[$expireAt, $limiterState] = $this->buckets[$limiterStateId];
35-
if (null !== $expireAt && $expireAt <= microtime(true)) {
40+
if (null !== $expireAt && $expireAt <= ($this->clock?->now()->format('U.u') ?? microtime(true))) {
3641
unset($this->buckets[$limiterStateId]);
3742

3843
return null;
@@ -53,7 +58,7 @@ public function delete(string $limiterStateId): void
5358
private function getExpireAt(LimiterStateInterface $limiterState): ?float
5459
{
5560
if (null !== $expireSeconds = $limiterState->getExpirationTime()) {
56-
return microtime(true) + $expireSeconds;
61+
return (float) ($this->clock?->now()->format('U.u') ?? microtime(true)) + $expireSeconds;
5762
}
5863

5964
return $this->buckets[$limiterState->getId()][0] ?? null;

0 commit comments

Comments
 (0)