diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php index 727a1f6364456..d27a38ef97be8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php @@ -25,6 +25,7 @@ abstract_arg('config'), abstract_arg('storage'), null, + service('clock')->nullOnInvalid(), ]) ; }; diff --git a/src/Symfony/Component/RateLimiter/ClockTrait.php b/src/Symfony/Component/RateLimiter/ClockTrait.php new file mode 100644 index 0000000000000..ee0ee95531642 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/ClockTrait.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter; + +use Psr\Clock\ClockInterface; + +/** + * @internal + */ +trait ClockTrait +{ + private ?ClockInterface $clock; + + /** + * @internal + */ + public function setClock(?ClockInterface $clock): void + { + $this->clock = $clock; + } + + private function now(): float + { + return (float) ($this->clock?->now()->format('U.u') ?? microtime(true)); + } +} diff --git a/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php b/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php index b351329152173..73c80fa5f6a02 100644 --- a/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/FixedWindowLimiter.php @@ -11,7 +11,9 @@ namespace Symfony\Component\RateLimiter\Policy; +use Psr\Clock\ClockInterface; use Symfony\Component\Lock\LockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; @@ -24,6 +26,7 @@ */ final class FixedWindowLimiter implements LimiterInterface { + use ClockTrait; use ResetLimiterTrait; private int $interval; @@ -34,6 +37,7 @@ public function __construct( \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null, + ?ClockInterface $clock = null, ) { if ($limit < 1) { throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__)); @@ -43,6 +47,7 @@ public function __construct( $this->lock = $lock; $this->id = $id; $this->interval = TimeUtil::dateIntervalToSeconds($interval); + $this->setClock($clock); } public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation @@ -56,30 +61,32 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation try { $window = $this->storage->fetch($this->id); if (!$window instanceof Window) { - $window = new Window($this->id, $this->interval, $this->limit); + $window = new Window($this->id, $this->interval, $this->limit, $this->clock); + } else { + $window->setClock($this->clock); } - $now = microtime(true); + $now = $this->now(); $availableTokens = $window->getAvailableTokens($now); if (0 === $tokens) { $waitDuration = $window->calculateTimeForTokens(1, $now); - $reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), true, $this->limit)); + $reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), true, $this->limit), $this->clock); } elseif ($availableTokens >= $tokens) { $window->add($tokens, $now); - $reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit)); + $reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit, $this->clock), $this->clock); } else { $waitDuration = $window->calculateTimeForTokens($tokens, $now); if (null !== $maxTime && $waitDuration > $maxTime) { // process needs to wait longer than set interval - 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)); + 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)); } $window->add($tokens, $now); - $reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit)); + $reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit, $this->clock), $this->clock); } if (0 < $tokens) { diff --git a/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php b/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php index 56339d372ad95..e79f7d4dff8ab 100644 --- a/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/NoLimiter.php @@ -11,6 +11,8 @@ namespace Symfony\Component\RateLimiter\Policy; +use Psr\Clock\ClockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; use Symfony\Component\RateLimiter\Reservation; @@ -25,14 +27,21 @@ */ final class NoLimiter implements LimiterInterface { + use ClockTrait; + + public function __construct(?ClockInterface $clock = null) + { + $this->setClock($clock); + } + public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation { - return new Reservation(microtime(true), new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX)); + return new Reservation($this->now(), new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX, $this->clock), $this->clock); } public function consume(int $tokens = 1): RateLimit { - return new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX); + return new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX, $this->clock); } public function reset(): void diff --git a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php index ab2e7674876c6..20ac0f7670b39 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindow.php @@ -11,6 +11,8 @@ namespace Symfony\Component\RateLimiter\Policy; +use Psr\Clock\ClockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\Exception\InvalidIntervalException; use Symfony\Component\RateLimiter\LimiterStateInterface; @@ -21,6 +23,8 @@ */ final class SlidingWindow implements LimiterStateInterface { + use ClockTrait; + private int $hitCount = 0; private int $hitCountForLastWindow = 0; private float $windowEndAt; @@ -28,19 +32,21 @@ final class SlidingWindow implements LimiterStateInterface public function __construct( private string $id, private int $intervalInSeconds, + ?ClockInterface $clock = null, ) { if ($intervalInSeconds < 1) { throw new InvalidIntervalException(\sprintf('The interval must be positive integer, "%d" given.', $intervalInSeconds)); } - $this->windowEndAt = microtime(true) + $intervalInSeconds; + $this->setClock($clock); + $this->windowEndAt = $this->now() + $intervalInSeconds; } - public static function createFromPreviousWindow(self $window, int $intervalInSeconds): self + public static function createFromPreviousWindow(self $window, int $intervalInSeconds, ?ClockInterface $clock = null): self { $new = new self($window->id, $intervalInSeconds); $windowEndAt = $window->windowEndAt + $intervalInSeconds; - if (microtime(true) < $windowEndAt) { + if (($clock?->now()->format('U.u') ?? microtime(true)) < $windowEndAt) { $new->hitCountForLastWindow = $window->hitCount; $new->windowEndAt = $windowEndAt; } @@ -58,12 +64,12 @@ public function getId(): string */ public function getExpirationTime(): int { - return (int) ($this->windowEndAt + $this->intervalInSeconds - microtime(true)); + return (int) ($this->windowEndAt + $this->intervalInSeconds - $this->now()); } public function isExpired(): bool { - return microtime(true) > $this->windowEndAt; + return $this->now() > $this->windowEndAt; } public function add(int $hits = 1): void @@ -77,7 +83,7 @@ public function add(int $hits = 1): void public function getHitCount(): int { $startOfWindow = $this->windowEndAt - $this->intervalInSeconds; - $percentOfCurrentTimeFrame = min((microtime(true) - $startOfWindow) / $this->intervalInSeconds, 1); + $percentOfCurrentTimeFrame = min(($this->now() - $startOfWindow) / $this->intervalInSeconds, 1); return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount); } @@ -89,7 +95,7 @@ public function calculateTimeForTokens(int $maxSize, int $tokens): float return 0; } - $time = microtime(true); + $time = $this->now(); $startOfWindow = $this->windowEndAt - $this->intervalInSeconds; $timePassed = $time - $startOfWindow; $windowPassed = min($timePassed / $this->intervalInSeconds, 1); diff --git a/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php b/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php index e3cceb0254d39..50914a13656d7 100644 --- a/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/SlidingWindowLimiter.php @@ -11,7 +11,9 @@ namespace Symfony\Component\RateLimiter\Policy; +use Psr\Clock\ClockInterface; use Symfony\Component\Lock\LockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; @@ -32,6 +34,7 @@ */ final class SlidingWindowLimiter implements LimiterInterface { + use ClockTrait; use ResetLimiterTrait; private int $interval; @@ -42,11 +45,13 @@ public function __construct( \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null, + ?ClockInterface $clock = null, ) { $this->storage = $storage; $this->lock = $lock; $this->id = $id; $this->interval = TimeUtil::dateIntervalToSeconds($interval); + $this->setClock($clock); } public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation @@ -59,25 +64,30 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation try { $window = $this->storage->fetch($this->id); - if (!$window instanceof SlidingWindow) { - $window = new SlidingWindow($this->id, $this->interval); - } elseif ($window->isExpired()) { - $window = SlidingWindow::createFromPreviousWindow($window, $this->interval); + + if ($window instanceof SlidingWindow) { + $window->setClock($this->clock); + + if ($window->isExpired()) { + $window = SlidingWindow::createFromPreviousWindow($window, $this->interval, $this->clock); + } + } else { + $window = new SlidingWindow($this->id, $this->interval, $this->clock); } - $now = microtime(true); + $now = $this->now(); $hitCount = $window->getHitCount(); $availableTokens = $this->getAvailableTokens($hitCount); if (0 === $tokens) { $resetDuration = $window->calculateTimeForTokens($this->limit, $window->getHitCount()); $resetTime = \DateTimeImmutable::createFromFormat('U', $availableTokens ? floor($now) : floor($now + $resetDuration)); - return new Reservation($now, new RateLimit($availableTokens, $resetTime, true, $this->limit)); + return new Reservation($now, new RateLimit($availableTokens, $resetTime, true, $this->limit, $this->clock), $this->clock); } if ($availableTokens >= $tokens) { $window->add($tokens); - $reservation = new Reservation($now, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit)); + $reservation = new Reservation($now, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit), $this->clock); } else { $waitDuration = $window->calculateTimeForTokens($this->limit, $tokens); @@ -88,7 +98,7 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation $window->add($tokens); - $reservation = new Reservation($now + $waitDuration, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit)); + $reservation = new Reservation($now + $waitDuration, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit), $this->clock); } if (0 < $tokens) { diff --git a/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php b/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php index 69eb74ce49fac..c446748987220 100644 --- a/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php +++ b/src/Symfony/Component/RateLimiter/Policy/TokenBucket.php @@ -11,6 +11,8 @@ namespace Symfony\Component\RateLimiter\Policy; +use Psr\Clock\ClockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\LimiterStateInterface; /** @@ -20,6 +22,8 @@ */ final class TokenBucket implements LimiterStateInterface { + use ClockTrait; + private int $tokens; private int $burstSize; private float $timer; @@ -28,22 +32,22 @@ final class TokenBucket implements LimiterStateInterface * @param string $id unique identifier for this bucket * @param int $initialTokens the initial number of tokens in the bucket (i.e. the max burst size) * @param Rate $rate the fill rate and time of this bucket - * @param float|null $timer the current timer of the bucket, defaulting to microtime(true) + * @param float|null $timer the current timer of the bucket, defaulting to the current time in microseconds */ public function __construct( private string $id, int $initialTokens, private Rate $rate, + ?ClockInterface $clock = null, ?float $timer = null, ) { if ($initialTokens < 1) { throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', TokenBucketLimiter::class)); } - $this->id = $id; $this->tokens = $this->burstSize = $initialTokens; - $this->rate = $rate; - $this->timer = $timer ?? microtime(true); + $this->setClock($clock); + $this->timer = $timer ?? $this->now(); } public function getId(): string diff --git a/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php b/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php index c9f6207e3e581..153b0888a3ac0 100644 --- a/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php +++ b/src/Symfony/Component/RateLimiter/Policy/TokenBucketLimiter.php @@ -11,7 +11,9 @@ namespace Symfony\Component\RateLimiter\Policy; +use Psr\Clock\ClockInterface; use Symfony\Component\Lock\LockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; @@ -23,6 +25,7 @@ */ final class TokenBucketLimiter implements LimiterInterface { + use ClockTrait; use ResetLimiterTrait; public function __construct( @@ -31,10 +34,12 @@ public function __construct( private Rate $rate, StorageInterface $storage, ?LockInterface $lock = null, + ?ClockInterface $clock = null, ) { $this->id = $id; $this->storage = $storage; $this->lock = $lock; + $this->setClock($clock); } /** @@ -61,10 +66,12 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation try { $bucket = $this->storage->fetch($this->id); if (!$bucket instanceof TokenBucket) { - $bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate); + $bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate, $this->clock); + } else { + $bucket->setClock($this->clock); } - $now = microtime(true); + $now = $this->now(); $availableTokens = $bucket->getAvailableTokens($now); if ($availableTokens >= $tokens) { @@ -80,14 +87,14 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation $waitTime = \DateTimeImmutable::createFromFormat('U', floor($now)); } - $reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), $waitTime, true, $this->maxBurst)); + $reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), $waitTime, true, $this->maxBurst, $this->clock), $this->clock); } else { $remainingTokens = $tokens - $availableTokens; $waitDuration = $this->rate->calculateTimeForTokens($remainingTokens); if (null !== $maxTime && $waitDuration > $maxTime) { // process needs to wait longer than set interval - $rateLimit = new RateLimit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst); + $rateLimit = new RateLimit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst, $this->clock); throw new MaxWaitDurationExceededException(\sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $rateLimit); } @@ -96,7 +103,7 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation // so no tokens are left for other processes. $bucket->setTokens($availableTokens - $tokens); - $reservation = new Reservation($now + $waitDuration, new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst)); + $reservation = new Reservation($now + $waitDuration, new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst, $this->clock), $this->clock); } if (0 < $tokens) { diff --git a/src/Symfony/Component/RateLimiter/Policy/Window.php b/src/Symfony/Component/RateLimiter/Policy/Window.php index a483fca8e9d93..00a82855311b4 100644 --- a/src/Symfony/Component/RateLimiter/Policy/Window.php +++ b/src/Symfony/Component/RateLimiter/Policy/Window.php @@ -11,6 +11,8 @@ namespace Symfony\Component\RateLimiter\Policy; +use Psr\Clock\ClockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\LimiterStateInterface; /** @@ -20,6 +22,8 @@ */ final class Window implements LimiterStateInterface { + use ClockTrait; + private int $hitCount = 0; private int $maxSize; private float $timer; @@ -28,10 +32,12 @@ public function __construct( private string $id, private int $intervalInSeconds, int $windowSize, + ?ClockInterface $clock = null, ?float $timer = null, ) { $this->maxSize = $windowSize; - $this->timer = $timer ?? microtime(true); + $this->setClock($clock); + $this->timer = $timer ?? $this->now(); } public function getId(): string @@ -46,7 +52,7 @@ public function getExpirationTime(): ?int public function add(int $hits = 1, ?float $now = null): void { - $now ??= microtime(true); + $now ??= $this->now(); if (($now - $this->timer) > $this->intervalInSeconds) { // reset window $this->timer = $now; diff --git a/src/Symfony/Component/RateLimiter/RateLimit.php b/src/Symfony/Component/RateLimiter/RateLimit.php index 70b93d62cb3c7..8a7acd40fb1fd 100644 --- a/src/Symfony/Component/RateLimiter/RateLimit.php +++ b/src/Symfony/Component/RateLimiter/RateLimit.php @@ -11,6 +11,7 @@ namespace Symfony\Component\RateLimiter; +use Psr\Clock\ClockInterface; use Symfony\Component\RateLimiter\Exception\RateLimitExceededException; /** @@ -18,12 +19,16 @@ */ class RateLimit { + use ClockTrait; + public function __construct( private int $availableTokens, private \DateTimeImmutable $retryAfter, private bool $accepted, private int $limit, + ?ClockInterface $clock = null, ) { + $this->setClock($clock); } public function isAccepted(): bool @@ -62,7 +67,7 @@ public function getLimit(): int public function wait(): void { - $delta = $this->retryAfter->format('U.u') - microtime(true); + $delta = $this->retryAfter->format('U.u') - $this->now(); if ($delta <= 0) { return; } diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php index def8c60855d4d..ee845b6ecaa2b 100644 --- a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php +++ b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php @@ -11,6 +11,7 @@ namespace Symfony\Component\RateLimiter; +use Psr\Clock\ClockInterface; use Symfony\Component\Lock\LockFactory; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -32,6 +33,7 @@ public function __construct( array $config, private StorageInterface $storage, private ?LockFactory $lockFactory = null, + private ?ClockInterface $clock = null, ) { $options = new OptionsResolver(); self::configureOptions($options); @@ -45,10 +47,10 @@ public function create(?string $key = null): LimiterInterface $lock = $this->lockFactory?->createLock($id); return match ($this->config['policy']) { - 'token_bucket' => new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock), - 'fixed_window' => new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock), - 'sliding_window' => new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock), - 'no_limit' => new NoLimiter(), + 'token_bucket' => new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock, $this->clock), + 'fixed_window' => new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock, $this->clock), + 'sliding_window' => new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock, $this->clock), + 'no_limit' => new NoLimiter($this->clock), 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'])), }; } diff --git a/src/Symfony/Component/RateLimiter/Reservation.php b/src/Symfony/Component/RateLimiter/Reservation.php index 34c28400620b2..d5cf8c383877b 100644 --- a/src/Symfony/Component/RateLimiter/Reservation.php +++ b/src/Symfony/Component/RateLimiter/Reservation.php @@ -11,18 +11,24 @@ namespace Symfony\Component\RateLimiter; +use Symfony\Component\Clock\ClockInterface; + /** * @author Wouter de Jong */ final class Reservation { + use ClockTrait; + /** * @param float $timeToAct Unix timestamp in seconds when this reservation should act */ public function __construct( private float $timeToAct, private RateLimit $rateLimit, + ?ClockInterface $clock = null, ) { + $this->setClock($clock); } public function getTimeToAct(): float @@ -32,7 +38,7 @@ public function getTimeToAct(): float public function getWaitDuration(): float { - return max(0, (-microtime(true)) + $this->timeToAct); + return max(0, (-$this->now()) + $this->timeToAct); } public function getRateLimit(): RateLimit diff --git a/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php b/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php index 1d7a8e49b1531..6d233e86b8c54 100644 --- a/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php +++ b/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php @@ -11,6 +11,8 @@ namespace Symfony\Component\RateLimiter\Storage; +use Psr\Clock\ClockInterface; +use Symfony\Component\RateLimiter\ClockTrait; use Symfony\Component\RateLimiter\LimiterStateInterface; /** @@ -18,8 +20,15 @@ */ class InMemoryStorage implements StorageInterface { + use ClockTrait; + private array $buckets = []; + public function __construct(?ClockInterface $clock = null) + { + $this->setClock($clock); + } + public function save(LimiterStateInterface $limiterState): void { $this->buckets[$limiterState->getId()] = [$this->getExpireAt($limiterState), serialize($limiterState)]; @@ -32,7 +41,7 @@ public function fetch(string $limiterStateId): ?LimiterStateInterface } [$expireAt, $limiterState] = $this->buckets[$limiterStateId]; - if (null !== $expireAt && $expireAt <= microtime(true)) { + if (null !== $expireAt && $expireAt <= $this->now()) { unset($this->buckets[$limiterStateId]); return null; @@ -53,7 +62,7 @@ public function delete(string $limiterStateId): void private function getExpireAt(LimiterStateInterface $limiterState): ?float { if (null !== $expireSeconds = $limiterState->getExpirationTime()) { - return microtime(true) + $expireSeconds; + return $this->now() + $expireSeconds; } return $this->buckets[$limiterState->getId()][0] ?? null; diff --git a/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php index 86e5d042646f2..40806cee135ab 100644 --- a/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/Policy/FixedWindowLimiterTest.php @@ -103,11 +103,11 @@ public function testWindowResilientToTimeShifting() { $serverOneClock = microtime(true) - 1; $serverTwoClock = microtime(true) + 1; - $window = new Window('id', 300, 100, $serverTwoClock); + $window = new Window('id', 300, 100, null, $serverTwoClock); $this->assertSame(100, $window->getAvailableTokens($serverTwoClock)); $this->assertSame(100, $window->getAvailableTokens($serverOneClock)); - $window = new Window('id', 300, 100, $serverOneClock); + $window = new Window('id', 300, 100, null, $serverOneClock); $this->assertSame(100, $window->getAvailableTokens($serverTwoClock)); $this->assertSame(100, $window->getAvailableTokens($serverOneClock)); } diff --git a/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php b/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php index 737c5566ea44e..903582923b2ec 100644 --- a/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/Policy/SlidingWindowTest.php @@ -30,6 +30,7 @@ public function testGetExpirationTime() $data = serialize($window); sleep(10); $cachedWindow = unserialize($data); + $cachedWindow->setClock(null); $this->assertSame(10, $cachedWindow->getExpirationTime()); $new = SlidingWindow::createFromPreviousWindow($cachedWindow, 15); diff --git a/src/Symfony/Component/RateLimiter/Tests/Policy/TokenBucketLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/Policy/TokenBucketLimiterTest.php index 5068e4231c293..b213ce74a8cd7 100644 --- a/src/Symfony/Component/RateLimiter/Tests/Policy/TokenBucketLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/Policy/TokenBucketLimiterTest.php @@ -121,11 +121,11 @@ public function testBucketResilientToTimeShifting() $serverOneClock = microtime(true) - 1; $serverTwoClock = microtime(true) + 1; - $bucket = new TokenBucket('id', 100, new Rate(\DateInterval::createFromDateString('5 minutes'), 10), $serverTwoClock); + $bucket = new TokenBucket('id', 100, new Rate(\DateInterval::createFromDateString('5 minutes'), 10), null, $serverTwoClock); $this->assertSame(100, $bucket->getAvailableTokens($serverTwoClock)); $this->assertSame(100, $bucket->getAvailableTokens($serverOneClock)); - $bucket = new TokenBucket('id', 100, new Rate(\DateInterval::createFromDateString('5 minutes'), 10), $serverOneClock); + $bucket = new TokenBucket('id', 100, new Rate(\DateInterval::createFromDateString('5 minutes'), 10), null, $serverOneClock); $this->assertSame(100, $bucket->getAvailableTokens($serverTwoClock)); $this->assertSame(100, $bucket->getAvailableTokens($serverOneClock)); }