This commit is contained in:
team3
2026-06-03 22:05:20 +02:00
parent d7e8df6876
commit 40de56c27b
5119 changed files with 552560 additions and 24 deletions

View File

@@ -0,0 +1,23 @@
CHANGELOG
=========
6.4
---
* Add `SlidingWindowLimiter::reserve()`
6.2
---
* Move `symfony/lock` to dev dependency in `composer.json`
5.4
---
* The component is not experimental anymore
* Add support for long intervals (months and years)
5.2.0
-----
* added the component

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class CompoundLimiter implements LimiterInterface
{
private array $limiters;
/**
* @param LimiterInterface[] $limiters
*/
public function __construct(array $limiters)
{
if (!$limiters) {
throw new \LogicException(\sprintf('"%s::%s()" require at least one limiter.', self::class, __METHOD__));
}
$this->limiters = $limiters;
}
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
{
throw new ReserveNotSupportedException(__CLASS__);
}
public function consume(int $tokens = 1): RateLimit
{
$minimalRateLimit = null;
foreach ($this->limiters as $limiter) {
$rateLimit = $limiter->consume($tokens);
if (
null === $minimalRateLimit
|| $rateLimit->getRemainingTokens() < $minimalRateLimit->getRemainingTokens()
|| ($minimalRateLimit->isAccepted() && !$rateLimit->isAccepted())
) {
$minimalRateLimit = $rateLimit;
}
}
return $minimalRateLimit;
}
public function reset(): void
{
foreach ($this->limiters as $limiter) {
$limiter->reset();
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Exception;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class InvalidIntervalException extends \LogicException
{
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Exception;
use Symfony\Component\RateLimiter\RateLimit;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class MaxWaitDurationExceededException extends \RuntimeException
{
private RateLimit $rateLimit;
public function __construct(string $message, RateLimit $rateLimit, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->rateLimit = $rateLimit;
}
public function getRateLimit(): RateLimit
{
return $this->rateLimit;
}
}

View File

@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Exception;
use Symfony\Component\RateLimiter\RateLimit;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
class RateLimitExceededException extends \RuntimeException
{
private RateLimit $rateLimit;
public function __construct(RateLimit $rateLimit, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct('Rate Limit Exceeded', $code, $previous);
$this->rateLimit = $rateLimit;
}
public function getRateLimit(): RateLimit
{
return $this->rateLimit;
}
public function getRetryAfter(): \DateTimeImmutable
{
return $this->rateLimit->getRetryAfter();
}
public function getRemainingTokens(): int
{
return $this->rateLimit->getRemainingTokens();
}
public function getLimit(): int
{
return $this->rateLimit->getLimit();
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Exception;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class ReserveNotSupportedException extends \BadMethodCallException
{
public function __construct(string $limiterClass, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct(\sprintf('Reserving tokens is not supported by "%s".', $limiterClass), $code, $previous);
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2016-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
interface LimiterInterface
{
/**
* Waits until the required number of tokens is available.
*
* The reserved tokens will be taken into account when calculating
* future token consumptions. Do not use this method if you intend
* to skip this process.
*
* @param int $tokens the number of tokens required
* @param float|null $maxTime maximum accepted waiting time in seconds
*
* @throws MaxWaitDurationExceededException if $maxTime is set and the process needs to wait longer than its value (in seconds)
* @throws ReserveNotSupportedException if this limiter implementation doesn't support reserving tokens
* @throws \InvalidArgumentException if $tokens is larger than the maximum burst size
*/
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation;
/**
* Use this method if you intend to drop if the required number
* of tokens is unavailable.
*
* @param int $tokens the number of tokens required
*/
public function consume(int $tokens = 1): RateLimit;
/**
* Resets the limit.
*/
public function reset(): void;
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
/**
* Representing the stored state of the limiter.
*
* Classes implementing this interface must be serializable,
* which is used by the storage implementations to store the
* object.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*/
interface LimiterStateInterface
{
public function getId(): string;
public function getExpirationTime(): ?int;
}

View File

@@ -0,0 +1,105 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\Reservation;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use Symfony\Component\RateLimiter\Util\TimeUtil;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class FixedWindowLimiter implements LimiterInterface
{
use ResetLimiterTrait;
private int $limit;
private int $interval;
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null)
{
if ($limit < 1) {
throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__));
}
$this->storage = $storage;
$this->lock = $lock;
$this->id = $id;
$this->limit = $limit;
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
}
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
{
if ($tokens > $this->limit) {
throw new \InvalidArgumentException(\sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit));
}
$this->lock?->acquire(true);
try {
$window = $this->storage->fetch($this->id);
if (!$window instanceof Window) {
$window = new Window($this->id, $this->interval, $this->limit);
}
$now = microtime(true);
$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));
} elseif ($availableTokens >= $tokens) {
$window->add($tokens, $now);
$reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit));
} 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));
}
$window->add($tokens, $now);
$reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
}
if (0 < $tokens) {
$this->storage->save($window);
}
} finally {
$this->lock?->release();
}
return $reservation;
}
public function consume(int $tokens = 1): RateLimit
{
try {
return $this->reserve($tokens, 0)->getRateLimit();
} catch (MaxWaitDurationExceededException $e) {
return $e->getRateLimit();
}
}
public function getAvailableTokens(int $hitCount): int
{
return $this->limit - $hitCount;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\Reservation;
/**
* Implements a non limiting limiter.
*
* This can be used in cases where an implementation requires a
* limiter, but no rate limit should be enforced.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class NoLimiter implements LimiterInterface
{
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));
}
public function consume(int $tokens = 1): RateLimit
{
return new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX);
}
public function reset(): void
{
}
}

View File

@@ -0,0 +1,118 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\RateLimiter\Util\TimeUtil;
/**
* Data object representing the fill rate of a token bucket.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class Rate
{
private \DateInterval $refillTime;
private int $refillAmount;
public function __construct(\DateInterval $refillTime, int $refillAmount = 1)
{
$this->refillTime = $refillTime;
$this->refillAmount = $refillAmount;
}
public static function perSecond(int $rate = 1): self
{
return new static(new \DateInterval('PT1S'), $rate);
}
public static function perMinute(int $rate = 1): self
{
return new static(new \DateInterval('PT1M'), $rate);
}
public static function perHour(int $rate = 1): self
{
return new static(new \DateInterval('PT1H'), $rate);
}
public static function perDay(int $rate = 1): self
{
return new static(new \DateInterval('P1D'), $rate);
}
public static function perMonth(int $rate = 1): self
{
return new static(new \DateInterval('P1M'), $rate);
}
public static function perYear(int $rate = 1): self
{
return new static(new \DateInterval('P1Y'), $rate);
}
/**
* @param string $string using the format: "%interval_spec%-%rate%", {@see DateInterval}
*/
public static function fromString(string $string): self
{
[$interval, $rate] = explode('-', $string, 2);
return new static(new \DateInterval($interval), $rate);
}
/**
* Calculates the time needed to free up the provided number of tokens in seconds.
*/
public function calculateTimeForTokens(int $tokens): int
{
$cyclesRequired = ceil($tokens / $this->refillAmount);
return TimeUtil::dateIntervalToSeconds($this->refillTime) * $cyclesRequired;
}
/**
* Calculates the next moment of token availability.
*/
public function calculateNextTokenAvailability(): \DateTimeImmutable
{
return (new \DateTimeImmutable())->add($this->refillTime);
}
/**
* Calculates the number of new free tokens during $duration.
*
* @param float $duration interval in seconds
*/
public function calculateNewTokensDuringInterval(float $duration): int
{
$cycles = floor($duration / TimeUtil::dateIntervalToSeconds($this->refillTime));
return $cycles * $this->refillAmount;
}
/**
* Calculates total amount in seconds of refill intervals during $duration (for maintain strict refill frequency).
*
* @param float $duration interval in seconds
*/
public function calculateRefillInterval(float $duration): int
{
$cycleTime = TimeUtil::dateIntervalToSeconds($this->refillTime);
return floor($duration / $cycleTime) * $cycleTime;
}
public function __toString(): string
{
return $this->refillTime->format('P%yY%mM%dDT%HH%iM%sS').'-'.$this->refillAmount;
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
trait ResetLimiterTrait
{
private ?LockInterface $lock;
private StorageInterface $storage;
private string $id;
public function reset(): void
{
try {
$this->lock?->acquire(true);
$this->storage->delete($this->id);
} finally {
$this->lock?->release();
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\RateLimiter\Exception\InvalidIntervalException;
use Symfony\Component\RateLimiter\LimiterStateInterface;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @internal
*/
final class SlidingWindow implements LimiterStateInterface
{
private string $id;
private int $hitCount = 0;
private int $hitCountForLastWindow = 0;
private int $intervalInSeconds;
private float $windowEndAt;
public function __construct(string $id, int $intervalInSeconds)
{
if ($intervalInSeconds < 1) {
throw new InvalidIntervalException(\sprintf('The interval must be positive integer, "%d" given.', $intervalInSeconds));
}
$this->id = $id;
$this->intervalInSeconds = $intervalInSeconds;
$this->windowEndAt = microtime(true) + $intervalInSeconds;
}
public static function createFromPreviousWindow(self $window, int $intervalInSeconds): self
{
$new = new self($window->id, $intervalInSeconds);
$windowEndAt = $window->windowEndAt + $intervalInSeconds;
if (microtime(true) < $windowEndAt) {
$new->hitCountForLastWindow = $window->hitCount;
$new->windowEndAt = $windowEndAt;
}
return $new;
}
public function getId(): string
{
return $this->id;
}
/**
* Returns the remaining of this timeframe and the next one.
*/
public function getExpirationTime(): int
{
return (int) ($this->windowEndAt + $this->intervalInSeconds - microtime(true));
}
public function isExpired(): bool
{
return microtime(true) > $this->windowEndAt;
}
public function add(int $hits = 1): void
{
$this->hitCount += $hits;
}
/**
* Calculates the sliding window number of request.
*/
public function getHitCount(): int
{
$startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
$percentOfCurrentTimeFrame = min((microtime(true) - $startOfWindow) / $this->intervalInSeconds, 1);
return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount);
}
/**
* @deprecated since Symfony 6.4, use {@see self::calculateTimeForTokens} instead
*/
public function getRetryAfter(): \DateTimeImmutable
{
trigger_deprecation('symfony/ratelimiter', '6.4', 'The "%s()" method is deprecated, use "%s::calculateTimeForTokens" instead.', __METHOD__, self::class);
return \DateTimeImmutable::createFromFormat('U.u', \sprintf('%.6F', microtime(true) + $this->calculateTimeForTokens(max(1, $this->getHitCount()), 1)));
}
public function calculateTimeForTokens(int $maxSize, int $tokens): float
{
$remaining = $maxSize - $this->getHitCount();
if ($remaining >= $tokens) {
return 0;
}
$time = microtime(true);
$startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
$timePassed = $time - $startOfWindow;
$windowPassed = min($timePassed / $this->intervalInSeconds, 1);
$releasable = max(1, $maxSize - floor($this->hitCountForLastWindow * (1 - $windowPassed)));
$remainingWindow = $this->intervalInSeconds - $timePassed;
$needed = $tokens - $remaining;
if ($releasable >= $needed) {
return $needed * ($remainingWindow / max(1, $releasable));
}
return ($this->windowEndAt - $time) + ($needed - $releasable) * ($this->intervalInSeconds / $maxSize);
}
public function __serialize(): array
{
return [
pack('NNN', $this->hitCount, $this->hitCountForLastWindow, $this->intervalInSeconds).$this->id => $this->windowEndAt,
];
}
public function __unserialize(array $data): void
{
// BC layer for old objects serialized via __sleep
if (5 === \count($data)) {
$data = array_values($data);
$this->id = $data[0];
$this->hitCount = $data[1];
$this->intervalInSeconds = $data[2];
$this->hitCountForLastWindow = $data[3];
$this->windowEndAt = $data[4];
return;
}
$pack = key($data);
$this->windowEndAt = $data[$pack];
['a' => $this->hitCount, 'b' => $this->hitCountForLastWindow, 'c' => $this->intervalInSeconds] = unpack('Na/Nb/Nc', $pack);
$this->id = substr($pack, 12);
}
}

View File

@@ -0,0 +1,114 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\Reservation;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use Symfony\Component\RateLimiter\Util\TimeUtil;
/**
* The sliding window algorithm will look at your last window and the current one.
* It is good algorithm to reduce bursts.
*
* Example:
* Last time window we did 8 hits. We are currently 25% into
* the current window. We have made 3 hits in the current window so far.
* That means our sliding window hit count is (75% * 8) + 3 = 9.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class SlidingWindowLimiter implements LimiterInterface
{
use ResetLimiterTrait;
private int $limit;
private int $interval;
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null)
{
$this->storage = $storage;
$this->lock = $lock;
$this->id = $id;
$this->limit = $limit;
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
}
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
{
if ($tokens > $this->limit) {
throw new \InvalidArgumentException(\sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit));
}
$this->lock?->acquire(true);
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);
}
$now = microtime(true);
$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));
}
if ($availableTokens >= $tokens) {
$window->add($tokens);
$reservation = new Reservation($now, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit));
} else {
$waitDuration = $window->calculateTimeForTokens($this->limit, $tokens);
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($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
}
$window->add($tokens);
$reservation = new Reservation($now + $waitDuration, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
}
if (0 < $tokens) {
$this->storage->save($window);
}
} finally {
$this->lock?->release();
}
return $reservation;
}
public function consume(int $tokens = 1): RateLimit
{
try {
return $this->reserve($tokens, 0)->getRateLimit();
} catch (MaxWaitDurationExceededException $e) {
return $e->getRateLimit();
}
}
private function getAvailableTokens(int $hitCount): int
{
return $this->limit - $hitCount;
}
}

View File

@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\RateLimiter\LimiterStateInterface;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @internal
*/
final class TokenBucket implements LimiterStateInterface
{
private string $id;
private Rate $rate;
private int $tokens;
private int $burstSize;
private float $timer;
/**
* @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)
*/
public function __construct(string $id, int $initialTokens, Rate $rate, ?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);
}
public function getId(): string
{
return $this->id;
}
public function setTimer(float $microtime): void
{
$this->timer = $microtime;
}
public function getTimer(): float
{
return $this->timer;
}
public function setTokens(int $tokens): void
{
$this->tokens = $tokens;
}
public function getAvailableTokens(float $now): int
{
$elapsed = max(0, $now - $this->timer);
$newTokens = $this->rate->calculateNewTokensDuringInterval($elapsed);
if ($newTokens > 0) {
$this->timer += $this->rate->calculateRefillInterval($elapsed);
}
return min($this->burstSize, $this->tokens + $newTokens);
}
public function getExpirationTime(): int
{
return $this->rate->calculateTimeForTokens($this->burstSize);
}
public function __serialize(): array
{
return [
pack('N', $this->burstSize).$this->id => $this->tokens,
(string) $this->rate => $this->timer,
];
}
public function __unserialize(array $data): void
{
// BC layer for old objects serialized via __sleep
if (5 === \count($data)) {
$data = array_values($data);
$this->id = $data[0];
$this->tokens = $data[1];
$this->timer = $data[2];
$this->burstSize = $data[3];
$this->rate = Rate::fromString($data[4]);
return;
}
[$this->tokens, $this->timer] = array_values($data);
[$pack, $rate] = array_keys($data);
$this->rate = Rate::fromString($rate);
$this->burstSize = unpack('Na', $pack)['a'];
$this->id = substr($pack, 4);
}
}

View File

@@ -0,0 +1,124 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\Reservation;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class TokenBucketLimiter implements LimiterInterface
{
use ResetLimiterTrait;
private int $maxBurst;
private Rate $rate;
public function __construct(string $id, int $maxBurst, Rate $rate, StorageInterface $storage, ?LockInterface $lock = null)
{
$this->id = $id;
$this->maxBurst = $maxBurst;
$this->rate = $rate;
$this->storage = $storage;
$this->lock = $lock;
}
/**
* Waits until the required number of tokens is available.
*
* The reserved tokens will be taken into account when calculating
* future token consumptions. Do not use this method if you intend
* to skip this process.
*
* @param int $tokens the number of tokens required
* @param float|null $maxTime maximum accepted waiting time in seconds
*
* @throws MaxWaitDurationExceededException if $maxTime is set and the process needs to wait longer than its value (in seconds)
* @throws \InvalidArgumentException if $tokens is larger than the maximum burst size
*/
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
{
if ($tokens > $this->maxBurst) {
throw new \InvalidArgumentException(\sprintf('Cannot reserve more tokens (%d) than the burst size of the rate limiter (%d).', $tokens, $this->maxBurst));
}
$this->lock?->acquire(true);
try {
$bucket = $this->storage->fetch($this->id);
if (!$bucket instanceof TokenBucket) {
$bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate);
}
$now = microtime(true);
$availableTokens = $bucket->getAvailableTokens($now);
if ($availableTokens > $this->maxBurst) {
$availableTokens = $this->maxBurst;
}
if ($availableTokens >= $tokens) {
// tokens are now available, update bucket
$bucket->setTokens($availableTokens - $tokens);
if (0 === $availableTokens) {
// This means 0 tokens where consumed (discouraged in most cases).
// Return the first time a new token is available
$waitDuration = $this->rate->calculateTimeForTokens(1);
$waitTime = \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration));
} else {
$waitTime = \DateTimeImmutable::createFromFormat('U', floor($now));
}
$reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), $waitTime, true, $this->maxBurst));
} 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);
throw new MaxWaitDurationExceededException(\sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $rateLimit);
}
// at $now + $waitDuration all tokens will be reserved for this process,
// 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));
}
if (0 < $tokens) {
$this->storage->save($bucket);
}
} finally {
$this->lock?->release();
}
return $reservation;
}
public function consume(int $tokens = 1): RateLimit
{
try {
return $this->reserve($tokens, 0)->getRateLimit();
} catch (MaxWaitDurationExceededException $e) {
return $e->getRateLimit();
}
}
}

View File

@@ -0,0 +1,109 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Policy;
use Symfony\Component\RateLimiter\LimiterStateInterface;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @internal
*/
final class Window implements LimiterStateInterface
{
private string $id;
private int $hitCount = 0;
private int $intervalInSeconds;
private int $maxSize;
private float $timer;
public function __construct(string $id, int $intervalInSeconds, int $windowSize, ?float $timer = null)
{
$this->id = $id;
$this->intervalInSeconds = $intervalInSeconds;
$this->maxSize = $windowSize;
$this->timer = $timer ?? microtime(true);
}
public function getId(): string
{
return $this->id;
}
public function getExpirationTime(): ?int
{
return $this->intervalInSeconds;
}
public function add(int $hits = 1, ?float $now = null): void
{
$now ??= microtime(true);
if (($now - $this->timer) > $this->intervalInSeconds) {
// reset window
$this->timer = $now;
$this->hitCount = 0;
}
$this->hitCount += $hits;
}
public function getHitCount(): int
{
return $this->hitCount;
}
public function getAvailableTokens(float $now): int
{
// if now is more than the window interval in the past, all tokens are available
if (($now - $this->timer) > $this->intervalInSeconds) {
return $this->maxSize;
}
return $this->maxSize - $this->hitCount;
}
public function calculateTimeForTokens(int $tokens, float $now): int
{
if (($this->maxSize - $this->hitCount) >= $tokens) {
return 0;
}
return (int) ceil($this->timer + $this->intervalInSeconds - $now);
}
public function __serialize(): array
{
return [
$this->id => $this->timer,
pack('NN', $this->hitCount, $this->intervalInSeconds) => $this->maxSize,
];
}
public function __unserialize(array $data): void
{
// BC layer for old objects serialized via __sleep
if (5 === \count($data)) {
$data = array_values($data);
$this->id = $data[0];
$this->hitCount = $data[1];
$this->intervalInSeconds = $data[2];
$this->maxSize = $data[3];
$this->timer = $data[4];
return;
}
[$this->timer, $this->maxSize] = array_values($data);
[$this->id, $pack] = array_keys($data);
['a' => $this->hitCount, 'b' => $this->intervalInSeconds] = unpack('Na/Nb', $pack);
}
}

View File

@@ -0,0 +1,43 @@
Rate Limiter Component
======================
The Rate Limiter component provides a Token Bucket implementation to
rate limit input and output in your application.
Getting Started
---------------
```
$ composer require symfony/rate-limiter
```
```php
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\RateLimiter\RateLimiterFactory;
$factory = new RateLimiterFactory([
'id' => 'login',
'policy' => 'token_bucket',
'limit' => 10,
'rate' => ['interval' => '15 minutes'],
], new InMemoryStorage());
$limiter = $factory->create();
// blocks until 1 token is free to use for this process
$limiter->reserve(1)->wait();
// ... execute the code
// only claims 1 token if it's free at this moment (useful if you plan to skip this process)
if ($limiter->consume(1)->isAccepted()) {
// ... execute the code
}
```
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
use Symfony\Component\RateLimiter\Exception\RateLimitExceededException;
/**
* @author Valentin Silvestre <vsilvestre.pro@gmail.com>
*/
class RateLimit
{
private int $availableTokens;
private \DateTimeImmutable $retryAfter;
private bool $accepted;
private int $limit;
public function __construct(int $availableTokens, \DateTimeImmutable $retryAfter, bool $accepted, int $limit)
{
$this->availableTokens = $availableTokens;
$this->retryAfter = $retryAfter;
$this->accepted = $accepted;
$this->limit = $limit;
}
public function isAccepted(): bool
{
return $this->accepted;
}
/**
* @return $this
*
* @throws RateLimitExceededException if not accepted
*/
public function ensureAccepted(): static
{
if (!$this->accepted) {
throw new RateLimitExceededException($this);
}
return $this;
}
public function getRetryAfter(): \DateTimeImmutable
{
return $this->retryAfter;
}
public function getRemainingTokens(): int
{
return $this->availableTokens;
}
public function getLimit(): int
{
return $this->limit;
}
public function wait(): void
{
$delta = $this->retryAfter->format('U.u') - microtime(true);
if ($delta <= 0) {
return;
}
usleep((int) ($delta * 1e6));
}
}

View File

@@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter;
use Symfony\Component\RateLimiter\Policy\NoLimiter;
use Symfony\Component\RateLimiter\Policy\Rate;
use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class RateLimiterFactory
{
private array $config;
private StorageInterface $storage;
private ?LockFactory $lockFactory;
public function __construct(array $config, StorageInterface $storage, ?LockFactory $lockFactory = null)
{
$this->storage = $storage;
$this->lockFactory = $lockFactory;
$options = new OptionsResolver();
self::configureOptions($options);
$this->config = $options->resolve($config);
}
public function create(?string $key = null): LimiterInterface
{
$id = $this->config['id'].'-'.$key;
$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(),
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'])),
};
}
protected static function configureOptions(OptionsResolver $options): void
{
$intervalNormalizer = static function (Options $options, string $interval): \DateInterval {
// Create DateTimeImmutable from unix timesatmp, so the default timezone is ignored and we don't need to
// deal with quirks happening when modifying dates using a timezone with DST.
$now = \DateTimeImmutable::createFromFormat('U', time());
try {
$nowPlusInterval = @$now->modify('+'.$interval);
} catch (\DateMalformedStringException $e) {
throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://php.net/datetime.formats#datetime.formats.relative', $interval), 0, $e);
}
if (!$nowPlusInterval) {
throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://php.net/datetime.formats#datetime.formats.relative', $interval));
}
return $now->diff($nowPlusInterval);
};
$options
->define('id')->required()
->define('policy')
->required()
->allowedValues('token_bucket', 'fixed_window', 'sliding_window', 'no_limit')
->define('limit')->allowedTypes('int')
->define('interval')->allowedTypes('string')->normalize($intervalNormalizer)
->define('rate')
->default(function (OptionsResolver $rate) use ($intervalNormalizer) {
$rate
->define('amount')->allowedTypes('int')->default(1)
->define('interval')->allowedTypes('string')->normalize($intervalNormalizer)
;
})
->normalize(function (Options $options, $value) {
if (!isset($value['interval'])) {
return null;
}
return new Rate($value['interval'], $value['amount']);
})
;
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
final class Reservation
{
private float $timeToAct;
private RateLimit $rateLimit;
/**
* @param float $timeToAct Unix timestamp in seconds when this reservation should act
*/
public function __construct(float $timeToAct, RateLimit $rateLimit)
{
$this->timeToAct = $timeToAct;
$this->rateLimit = $rateLimit;
}
public function getTimeToAct(): float
{
return $this->timeToAct;
}
public function getWaitDuration(): float
{
return max(0, (-microtime(true)) + $this->timeToAct);
}
public function getRateLimit(): RateLimit
{
return $this->rateLimit;
}
public function wait(): void
{
usleep((int) ($this->getWaitDuration() * 1e6));
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Storage;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\RateLimiter\LimiterStateInterface;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class CacheStorage implements StorageInterface
{
private CacheItemPoolInterface $pool;
public function __construct(CacheItemPoolInterface $pool)
{
$this->pool = $pool;
}
public function save(LimiterStateInterface $limiterState): void
{
$cacheItem = $this->pool->getItem(sha1($limiterState->getId()));
$cacheItem->set($limiterState);
if (null !== ($expireAfter = $limiterState->getExpirationTime())) {
$cacheItem->expiresAfter($expireAfter);
}
$this->pool->save($cacheItem);
}
public function fetch(string $limiterStateId): ?LimiterStateInterface
{
$cacheItem = $this->pool->getItem(sha1($limiterStateId));
$value = $cacheItem->get();
if ($value instanceof LimiterStateInterface) {
return $value;
}
return null;
}
public function delete(string $limiterStateId): void
{
$this->pool->deleteItem(sha1($limiterStateId));
}
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Storage;
use Symfony\Component\RateLimiter\LimiterStateInterface;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class InMemoryStorage implements StorageInterface
{
private array $buckets = [];
public function save(LimiterStateInterface $limiterState): void
{
$this->buckets[$limiterState->getId()] = [$this->getExpireAt($limiterState), serialize($limiterState)];
}
public function fetch(string $limiterStateId): ?LimiterStateInterface
{
if (!isset($this->buckets[$limiterStateId])) {
return null;
}
[$expireAt, $limiterState] = $this->buckets[$limiterStateId];
if (null !== $expireAt && $expireAt <= microtime(true)) {
unset($this->buckets[$limiterStateId]);
return null;
}
return unserialize($limiterState);
}
public function delete(string $limiterStateId): void
{
if (!isset($this->buckets[$limiterStateId])) {
return;
}
unset($this->buckets[$limiterStateId]);
}
private function getExpireAt(LimiterStateInterface $limiterState): ?float
{
if (null !== $expireSeconds = $limiterState->getExpirationTime()) {
return microtime(true) + $expireSeconds;
}
return $this->buckets[$limiterState->getId()][0] ?? null;
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Storage;
use Symfony\Component\RateLimiter\LimiterStateInterface;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*/
interface StorageInterface
{
public function save(LimiterStateInterface $limiterState): void;
public function fetch(string $limiterStateId): ?LimiterStateInterface;
public function delete(string $limiterStateId): void;
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\RateLimiter\Util;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @internal
*/
final class TimeUtil
{
public static function dateIntervalToSeconds(\DateInterval $interval): int
{
$now = \DateTimeImmutable::createFromFormat('U', time());
return $now->add($interval)->getTimestamp() - $now->getTimestamp();
}
}

View File

@@ -0,0 +1,34 @@
{
"name": "symfony/rate-limiter",
"type": "library",
"description": "Provides a Token Bucket implementation to rate limit input and output in your application",
"keywords": ["limiter", "rate-limiter"],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Wouter de Jong",
"email": "wouter@wouterj.nl"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/options-resolver": "^5.4|^6.0|^7.0"
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
"symfony/lock": "^5.4|^6.0|^7.0"
},
"autoload": {
"psr-4": { "Symfony\\Component\\RateLimiter\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}