├── CHANGELOG.md ├── CompoundLimiter.php ├── CompoundRateLimiterFactory.php ├── Exception ├── InvalidIntervalException.php ├── MaxWaitDurationExceededException.php ├── RateLimitExceededException.php └── ReserveNotSupportedException.php ├── LICENSE ├── LimiterInterface.php ├── LimiterStateInterface.php ├── Policy ├── FixedWindowLimiter.php ├── NoLimiter.php ├── Rate.php ├── ResetLimiterTrait.php ├── SlidingWindow.php ├── SlidingWindowLimiter.php ├── TokenBucket.php ├── TokenBucketLimiter.php └── Window.php ├── README.md ├── RateLimit.php ├── RateLimiterFactory.php ├── RateLimiterFactoryInterface.php ├── Reservation.php ├── Storage ├── CacheStorage.php ├── InMemoryStorage.php └── StorageInterface.php ├── Util └── TimeUtil.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Add `RateLimiterFactoryInterface` 8 | * Add `CompoundRateLimiterFactory` 9 | 10 | 6.4 11 | --- 12 | 13 | * Add `SlidingWindowLimiter::reserve()` 14 | 15 | 6.2 16 | --- 17 | 18 | * Move `symfony/lock` to dev dependency in `composer.json` 19 | 20 | 5.4 21 | --- 22 | 23 | * The component is not experimental anymore 24 | * Add support for long intervals (months and years) 25 | 26 | 5.2.0 27 | ----- 28 | 29 | * added the component 30 | -------------------------------------------------------------------------------- /CompoundLimiter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; 15 | 16 | /** 17 | * @author Wouter de Jong 18 | */ 19 | final class CompoundLimiter implements LimiterInterface 20 | { 21 | /** 22 | * @param LimiterInterface[] $limiters 23 | */ 24 | public function __construct( 25 | private array $limiters, 26 | ) { 27 | if (!$limiters) { 28 | throw new \LogicException(\sprintf('"%s::%s()" require at least one limiter.', self::class, __METHOD__)); 29 | } 30 | } 31 | 32 | public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation 33 | { 34 | throw new ReserveNotSupportedException(__CLASS__); 35 | } 36 | 37 | public function consume(int $tokens = 1): RateLimit 38 | { 39 | $minimalRateLimit = null; 40 | foreach ($this->limiters as $limiter) { 41 | $rateLimit = $limiter->consume($tokens); 42 | 43 | if ( 44 | null === $minimalRateLimit 45 | || $rateLimit->getRemainingTokens() < $minimalRateLimit->getRemainingTokens() 46 | || ($minimalRateLimit->isAccepted() && !$rateLimit->isAccepted()) 47 | ) { 48 | $minimalRateLimit = $rateLimit; 49 | } 50 | } 51 | 52 | return $minimalRateLimit; 53 | } 54 | 55 | public function reset(): void 56 | { 57 | foreach ($this->limiters as $limiter) { 58 | $limiter->reset(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /CompoundRateLimiterFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class CompoundRateLimiterFactory implements RateLimiterFactoryInterface 18 | { 19 | /** 20 | * @param iterable $rateLimiterFactories 21 | */ 22 | public function __construct(private iterable $rateLimiterFactories) 23 | { 24 | } 25 | 26 | public function create(?string $key = null): LimiterInterface 27 | { 28 | $rateLimiters = []; 29 | 30 | foreach ($this->rateLimiterFactories as $rateLimiterFactory) { 31 | $rateLimiters[] = $rateLimiterFactory->create($key); 32 | } 33 | 34 | return new CompoundLimiter($rateLimiters); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Exception/InvalidIntervalException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Exception; 13 | 14 | /** 15 | * @author Tobias Nyholm 16 | */ 17 | class InvalidIntervalException extends \LogicException 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/MaxWaitDurationExceededException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Exception; 13 | 14 | use Symfony\Component\RateLimiter\RateLimit; 15 | 16 | /** 17 | * @author Wouter de Jong 18 | */ 19 | class MaxWaitDurationExceededException extends \RuntimeException 20 | { 21 | public function __construct( 22 | string $message, 23 | private RateLimit $rateLimit, 24 | int $code = 0, 25 | ?\Throwable $previous = null, 26 | ) { 27 | parent::__construct($message, $code, $previous); 28 | } 29 | 30 | public function getRateLimit(): RateLimit 31 | { 32 | return $this->rateLimit; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Exception/RateLimitExceededException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Exception; 13 | 14 | use Symfony\Component\RateLimiter\RateLimit; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | class RateLimitExceededException extends \RuntimeException 20 | { 21 | public function __construct( 22 | private RateLimit $rateLimit, 23 | int $code = 0, 24 | ?\Throwable $previous = null, 25 | ) { 26 | parent::__construct('Rate Limit Exceeded', $code, $previous); 27 | } 28 | 29 | public function getRateLimit(): RateLimit 30 | { 31 | return $this->rateLimit; 32 | } 33 | 34 | public function getRetryAfter(): \DateTimeImmutable 35 | { 36 | return $this->rateLimit->getRetryAfter(); 37 | } 38 | 39 | public function getRemainingTokens(): int 40 | { 41 | return $this->rateLimit->getRemainingTokens(); 42 | } 43 | 44 | public function getLimit(): int 45 | { 46 | return $this->rateLimit->getLimit(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Exception/ReserveNotSupportedException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Exception; 13 | 14 | /** 15 | * @author Wouter de Jong 16 | */ 17 | class ReserveNotSupportedException extends \BadMethodCallException 18 | { 19 | public function __construct(string $limiterClass, int $code = 0, ?\Throwable $previous = null) 20 | { 21 | parent::__construct(\sprintf('Reserving tokens is not supported by "%s".', $limiterClass), $code, $previous); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /LimiterInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; 15 | use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; 16 | 17 | /** 18 | * @author Wouter de Jong 19 | */ 20 | interface LimiterInterface 21 | { 22 | /** 23 | * Waits until the required number of tokens is available. 24 | * 25 | * The reserved tokens will be taken into account when calculating 26 | * future token consumptions. Do not use this method if you intend 27 | * to skip this process. 28 | * 29 | * @param int $tokens the number of tokens required 30 | * @param float|null $maxTime maximum accepted waiting time in seconds 31 | * 32 | * @throws MaxWaitDurationExceededException if $maxTime is set and the process needs to wait longer than its value (in seconds) 33 | * @throws ReserveNotSupportedException if this limiter implementation doesn't support reserving tokens 34 | * @throws \InvalidArgumentException if $tokens is larger than the maximum burst size 35 | */ 36 | public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation; 37 | 38 | /** 39 | * Use this method if you intend to drop if the required number 40 | * of tokens is unavailable. 41 | * 42 | * @param int $tokens the number of tokens required 43 | */ 44 | public function consume(int $tokens = 1): RateLimit; 45 | 46 | /** 47 | * Resets the limit. 48 | */ 49 | public function reset(): void; 50 | } 51 | -------------------------------------------------------------------------------- /LimiterStateInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | /** 15 | * Representing the stored state of the limiter. 16 | * 17 | * Classes implementing this interface must be serializable, 18 | * which is used by the storage implementations to store the 19 | * object. 20 | * 21 | * @author Wouter de Jong 22 | */ 23 | interface LimiterStateInterface 24 | { 25 | public function getId(): string; 26 | 27 | public function getExpirationTime(): ?int; 28 | } 29 | -------------------------------------------------------------------------------- /Policy/FixedWindowLimiter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\Lock\LockInterface; 15 | use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; 16 | use Symfony\Component\RateLimiter\LimiterInterface; 17 | use Symfony\Component\RateLimiter\RateLimit; 18 | use Symfony\Component\RateLimiter\Reservation; 19 | use Symfony\Component\RateLimiter\Storage\StorageInterface; 20 | use Symfony\Component\RateLimiter\Util\TimeUtil; 21 | 22 | /** 23 | * @author Wouter de Jong 24 | */ 25 | final class FixedWindowLimiter implements LimiterInterface 26 | { 27 | use ResetLimiterTrait; 28 | 29 | private int $interval; 30 | 31 | public function __construct( 32 | string $id, 33 | private int $limit, 34 | \DateInterval $interval, 35 | StorageInterface $storage, 36 | ?LockInterface $lock = null, 37 | ) { 38 | if ($limit < 1) { 39 | throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__)); 40 | } 41 | 42 | $this->storage = $storage; 43 | $this->lock = $lock; 44 | $this->id = $id; 45 | $this->interval = TimeUtil::dateIntervalToSeconds($interval); 46 | } 47 | 48 | public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation 49 | { 50 | if ($tokens > $this->limit) { 51 | throw new \InvalidArgumentException(\sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit)); 52 | } 53 | 54 | $this->lock?->acquire(true); 55 | 56 | try { 57 | $window = $this->storage->fetch($this->id); 58 | if (!$window instanceof Window) { 59 | $window = new Window($this->id, $this->interval, $this->limit); 60 | } 61 | 62 | $now = microtime(true); 63 | $availableTokens = $window->getAvailableTokens($now); 64 | 65 | if (0 === $tokens) { 66 | $waitDuration = $window->calculateTimeForTokens(1, $now); 67 | $reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), true, $this->limit)); 68 | } elseif ($availableTokens >= $tokens) { 69 | $window->add($tokens, $now); 70 | 71 | $reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit)); 72 | } else { 73 | $waitDuration = $window->calculateTimeForTokens($tokens, $now); 74 | 75 | if (null !== $maxTime && $waitDuration > $maxTime) { 76 | // 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)); 78 | } 79 | 80 | $window->add($tokens, $now); 81 | 82 | $reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit)); 83 | } 84 | 85 | if (0 < $tokens) { 86 | $this->storage->save($window); 87 | } 88 | } finally { 89 | $this->lock?->release(); 90 | } 91 | 92 | return $reservation; 93 | } 94 | 95 | public function consume(int $tokens = 1): RateLimit 96 | { 97 | try { 98 | return $this->reserve($tokens, 0)->getRateLimit(); 99 | } catch (MaxWaitDurationExceededException $e) { 100 | return $e->getRateLimit(); 101 | } 102 | } 103 | 104 | public function getAvailableTokens(int $hitCount): int 105 | { 106 | return $this->limit - $hitCount; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Policy/NoLimiter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\RateLimiter\LimiterInterface; 15 | use Symfony\Component\RateLimiter\RateLimit; 16 | use Symfony\Component\RateLimiter\Reservation; 17 | 18 | /** 19 | * Implements a non limiting limiter. 20 | * 21 | * This can be used in cases where an implementation requires a 22 | * limiter, but no rate limit should be enforced. 23 | * 24 | * @author Wouter de Jong 25 | */ 26 | final class NoLimiter implements LimiterInterface 27 | { 28 | public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation 29 | { 30 | return new Reservation(microtime(true), new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX)); 31 | } 32 | 33 | public function consume(int $tokens = 1): RateLimit 34 | { 35 | return new RateLimit(\PHP_INT_MAX, new \DateTimeImmutable(), true, \PHP_INT_MAX); 36 | } 37 | 38 | public function reset(): void 39 | { 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Policy/Rate.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\RateLimiter\Util\TimeUtil; 15 | 16 | /** 17 | * Data object representing the fill rate of a token bucket. 18 | * 19 | * @author Wouter de Jong 20 | */ 21 | final class Rate 22 | { 23 | public function __construct( 24 | private \DateInterval $refillTime, 25 | private int $refillAmount = 1, 26 | ) { 27 | } 28 | 29 | public static function perSecond(int $rate = 1): self 30 | { 31 | return new static(new \DateInterval('PT1S'), $rate); 32 | } 33 | 34 | public static function perMinute(int $rate = 1): self 35 | { 36 | return new static(new \DateInterval('PT1M'), $rate); 37 | } 38 | 39 | public static function perHour(int $rate = 1): self 40 | { 41 | return new static(new \DateInterval('PT1H'), $rate); 42 | } 43 | 44 | public static function perDay(int $rate = 1): self 45 | { 46 | return new static(new \DateInterval('P1D'), $rate); 47 | } 48 | 49 | public static function perMonth(int $rate = 1): self 50 | { 51 | return new static(new \DateInterval('P1M'), $rate); 52 | } 53 | 54 | public static function perYear(int $rate = 1): self 55 | { 56 | return new static(new \DateInterval('P1Y'), $rate); 57 | } 58 | 59 | /** 60 | * @param string $string using the format: "%interval_spec%-%rate%", {@see DateInterval} 61 | */ 62 | public static function fromString(string $string): self 63 | { 64 | [$interval, $rate] = explode('-', $string, 2); 65 | 66 | return new static(new \DateInterval($interval), $rate); 67 | } 68 | 69 | /** 70 | * Calculates the time needed to free up the provided number of tokens in seconds. 71 | */ 72 | public function calculateTimeForTokens(int $tokens): int 73 | { 74 | $cyclesRequired = ceil($tokens / $this->refillAmount); 75 | 76 | return TimeUtil::dateIntervalToSeconds($this->refillTime) * $cyclesRequired; 77 | } 78 | 79 | /** 80 | * Calculates the next moment of token availability. 81 | */ 82 | public function calculateNextTokenAvailability(): \DateTimeImmutable 83 | { 84 | return (new \DateTimeImmutable())->add($this->refillTime); 85 | } 86 | 87 | /** 88 | * Calculates the number of new free tokens during $duration. 89 | * 90 | * @param float $duration interval in seconds 91 | */ 92 | public function calculateNewTokensDuringInterval(float $duration): int 93 | { 94 | $cycles = floor($duration / TimeUtil::dateIntervalToSeconds($this->refillTime)); 95 | 96 | return $cycles * $this->refillAmount; 97 | } 98 | 99 | /** 100 | * Calculates total amount in seconds of refill intervals during $duration (for maintain strict refill frequency). 101 | * 102 | * @param float $duration interval in seconds 103 | */ 104 | public function calculateRefillInterval(float $duration): int 105 | { 106 | $cycleTime = TimeUtil::dateIntervalToSeconds($this->refillTime); 107 | 108 | return floor($duration / $cycleTime) * $cycleTime; 109 | } 110 | 111 | public function __toString(): string 112 | { 113 | return $this->refillTime->format('P%yY%mM%dDT%HH%iM%sS').'-'.$this->refillAmount; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Policy/ResetLimiterTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\Lock\LockInterface; 15 | use Symfony\Component\RateLimiter\Storage\StorageInterface; 16 | 17 | trait ResetLimiterTrait 18 | { 19 | private ?LockInterface $lock; 20 | private StorageInterface $storage; 21 | private string $id; 22 | 23 | public function reset(): void 24 | { 25 | try { 26 | $this->lock?->acquire(true); 27 | 28 | $this->storage->delete($this->id); 29 | } finally { 30 | $this->lock?->release(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Policy/SlidingWindow.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\RateLimiter\Exception\InvalidIntervalException; 15 | use Symfony\Component\RateLimiter\LimiterStateInterface; 16 | 17 | /** 18 | * @author Tobias Nyholm 19 | * 20 | * @internal 21 | */ 22 | final class SlidingWindow implements LimiterStateInterface 23 | { 24 | private int $hitCount = 0; 25 | private int $hitCountForLastWindow = 0; 26 | private float $windowEndAt; 27 | 28 | public function __construct( 29 | private string $id, 30 | private int $intervalInSeconds, 31 | ) { 32 | if ($intervalInSeconds < 1) { 33 | throw new InvalidIntervalException(\sprintf('The interval must be positive integer, "%d" given.', $intervalInSeconds)); 34 | } 35 | $this->windowEndAt = microtime(true) + $intervalInSeconds; 36 | } 37 | 38 | public static function createFromPreviousWindow(self $window, int $intervalInSeconds): self 39 | { 40 | $new = new self($window->id, $intervalInSeconds); 41 | $windowEndAt = $window->windowEndAt + $intervalInSeconds; 42 | 43 | if (microtime(true) < $windowEndAt) { 44 | $new->hitCountForLastWindow = $window->hitCount; 45 | $new->windowEndAt = $windowEndAt; 46 | } 47 | 48 | return $new; 49 | } 50 | 51 | public function getId(): string 52 | { 53 | return $this->id; 54 | } 55 | 56 | /** 57 | * Returns the remaining of this timeframe and the next one. 58 | */ 59 | public function getExpirationTime(): int 60 | { 61 | return (int) ($this->windowEndAt + $this->intervalInSeconds - microtime(true)); 62 | } 63 | 64 | public function isExpired(): bool 65 | { 66 | return microtime(true) > $this->windowEndAt; 67 | } 68 | 69 | public function add(int $hits = 1): void 70 | { 71 | $this->hitCount += $hits; 72 | } 73 | 74 | /** 75 | * Calculates the sliding window number of request. 76 | */ 77 | public function getHitCount(): int 78 | { 79 | $startOfWindow = $this->windowEndAt - $this->intervalInSeconds; 80 | $percentOfCurrentTimeFrame = min((microtime(true) - $startOfWindow) / $this->intervalInSeconds, 1); 81 | 82 | return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount); 83 | } 84 | 85 | public function calculateTimeForTokens(int $maxSize, int $tokens): float 86 | { 87 | $remaining = $maxSize - $this->getHitCount(); 88 | if ($remaining >= $tokens) { 89 | return 0; 90 | } 91 | 92 | $time = microtime(true); 93 | $startOfWindow = $this->windowEndAt - $this->intervalInSeconds; 94 | $timePassed = $time - $startOfWindow; 95 | $windowPassed = min($timePassed / $this->intervalInSeconds, 1); 96 | $releasable = max(1, $maxSize - floor($this->hitCountForLastWindow * (1 - $windowPassed))); 97 | $remainingWindow = $this->intervalInSeconds - $timePassed; 98 | $needed = $tokens - $remaining; 99 | 100 | if ($releasable >= $needed) { 101 | return $needed * ($remainingWindow / max(1, $releasable)); 102 | } 103 | 104 | return ($this->windowEndAt - $time) + ($needed - $releasable) * ($this->intervalInSeconds / $maxSize); 105 | } 106 | 107 | public function __serialize(): array 108 | { 109 | return [ 110 | pack('NNN', $this->hitCount, $this->hitCountForLastWindow, $this->intervalInSeconds).$this->id => $this->windowEndAt, 111 | ]; 112 | } 113 | 114 | public function __unserialize(array $data): void 115 | { 116 | // BC layer for old objects serialized via __sleep 117 | if (5 === \count($data)) { 118 | $data = array_values($data); 119 | $this->id = $data[0]; 120 | $this->hitCount = $data[1]; 121 | $this->intervalInSeconds = $data[2]; 122 | $this->hitCountForLastWindow = $data[3]; 123 | $this->windowEndAt = $data[4]; 124 | 125 | return; 126 | } 127 | 128 | $pack = key($data); 129 | $this->windowEndAt = $data[$pack]; 130 | ['a' => $this->hitCount, 'b' => $this->hitCountForLastWindow, 'c' => $this->intervalInSeconds] = unpack('Na/Nb/Nc', $pack); 131 | $this->id = substr($pack, 12); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Policy/SlidingWindowLimiter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\Lock\LockInterface; 15 | use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; 16 | use Symfony\Component\RateLimiter\LimiterInterface; 17 | use Symfony\Component\RateLimiter\RateLimit; 18 | use Symfony\Component\RateLimiter\Reservation; 19 | use Symfony\Component\RateLimiter\Storage\StorageInterface; 20 | use Symfony\Component\RateLimiter\Util\TimeUtil; 21 | 22 | /** 23 | * The sliding window algorithm will look at your last window and the current one. 24 | * It is good algorithm to reduce bursts. 25 | * 26 | * Example: 27 | * Last time window we did 8 hits. We are currently 25% into 28 | * the current window. We have made 3 hits in the current window so far. 29 | * That means our sliding window hit count is (75% * 8) + 3 = 9. 30 | * 31 | * @author Tobias Nyholm 32 | */ 33 | final class SlidingWindowLimiter implements LimiterInterface 34 | { 35 | use ResetLimiterTrait; 36 | 37 | private int $interval; 38 | 39 | public function __construct( 40 | string $id, 41 | private int $limit, 42 | \DateInterval $interval, 43 | StorageInterface $storage, 44 | ?LockInterface $lock = null, 45 | ) { 46 | $this->storage = $storage; 47 | $this->lock = $lock; 48 | $this->id = $id; 49 | $this->interval = TimeUtil::dateIntervalToSeconds($interval); 50 | } 51 | 52 | public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation 53 | { 54 | if ($tokens > $this->limit) { 55 | throw new \InvalidArgumentException(\sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit)); 56 | } 57 | 58 | $this->lock?->acquire(true); 59 | 60 | try { 61 | $window = $this->storage->fetch($this->id); 62 | if (!$window instanceof SlidingWindow) { 63 | $window = new SlidingWindow($this->id, $this->interval); 64 | } elseif ($window->isExpired()) { 65 | $window = SlidingWindow::createFromPreviousWindow($window, $this->interval); 66 | } 67 | 68 | $now = microtime(true); 69 | $hitCount = $window->getHitCount(); 70 | $availableTokens = $this->getAvailableTokens($hitCount); 71 | if (0 === $tokens) { 72 | $resetDuration = $window->calculateTimeForTokens($this->limit, $window->getHitCount()); 73 | $resetTime = \DateTimeImmutable::createFromFormat('U', $availableTokens ? floor($now) : floor($now + $resetDuration)); 74 | 75 | return new Reservation($now, new RateLimit($availableTokens, $resetTime, true, $this->limit)); 76 | } 77 | if ($availableTokens >= $tokens) { 78 | $window->add($tokens); 79 | 80 | $reservation = new Reservation($now, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit)); 81 | } else { 82 | $waitDuration = $window->calculateTimeForTokens($this->limit, $tokens); 83 | 84 | if (null !== $maxTime && $waitDuration > $maxTime) { 85 | // process needs to wait longer than set interval 86 | 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)); 87 | } 88 | 89 | $window->add($tokens); 90 | 91 | $reservation = new Reservation($now + $waitDuration, new RateLimit($this->getAvailableTokens($window->getHitCount()), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit)); 92 | } 93 | 94 | if (0 < $tokens) { 95 | $this->storage->save($window); 96 | } 97 | } finally { 98 | $this->lock?->release(); 99 | } 100 | 101 | return $reservation; 102 | } 103 | 104 | public function consume(int $tokens = 1): RateLimit 105 | { 106 | try { 107 | return $this->reserve($tokens, 0)->getRateLimit(); 108 | } catch (MaxWaitDurationExceededException $e) { 109 | return $e->getRateLimit(); 110 | } 111 | } 112 | 113 | private function getAvailableTokens(int $hitCount): int 114 | { 115 | return $this->limit - $hitCount; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Policy/TokenBucket.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\RateLimiter\LimiterStateInterface; 15 | 16 | /** 17 | * @author Wouter de Jong 18 | * 19 | * @internal 20 | */ 21 | final class TokenBucket implements LimiterStateInterface 22 | { 23 | private int $tokens; 24 | private int $burstSize; 25 | private float $timer; 26 | 27 | /** 28 | * @param string $id unique identifier for this bucket 29 | * @param int $initialTokens the initial number of tokens in the bucket (i.e. the max burst size) 30 | * @param Rate $rate the fill rate and time of this bucket 31 | * @param float|null $timer the current timer of the bucket, defaulting to microtime(true) 32 | */ 33 | public function __construct( 34 | private string $id, 35 | int $initialTokens, 36 | private Rate $rate, 37 | ?float $timer = null, 38 | ) { 39 | if ($initialTokens < 1) { 40 | throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', TokenBucketLimiter::class)); 41 | } 42 | 43 | $this->id = $id; 44 | $this->tokens = $this->burstSize = $initialTokens; 45 | $this->rate = $rate; 46 | $this->timer = $timer ?? microtime(true); 47 | } 48 | 49 | public function getId(): string 50 | { 51 | return $this->id; 52 | } 53 | 54 | public function setTimer(float $microtime): void 55 | { 56 | $this->timer = $microtime; 57 | } 58 | 59 | public function getTimer(): float 60 | { 61 | return $this->timer; 62 | } 63 | 64 | public function setTokens(int $tokens): void 65 | { 66 | $this->tokens = $tokens; 67 | } 68 | 69 | public function getAvailableTokens(float $now): int 70 | { 71 | $elapsed = max(0, $now - $this->timer); 72 | $newTokens = $this->rate->calculateNewTokensDuringInterval($elapsed); 73 | 74 | if ($newTokens > 0) { 75 | $this->timer += $this->rate->calculateRefillInterval($elapsed); 76 | } 77 | 78 | return min($this->burstSize, $this->tokens + $newTokens); 79 | } 80 | 81 | public function getExpirationTime(): int 82 | { 83 | return $this->rate->calculateTimeForTokens($this->burstSize); 84 | } 85 | 86 | public function __serialize(): array 87 | { 88 | return [ 89 | pack('N', $this->burstSize).$this->id => $this->tokens, 90 | (string) $this->rate => $this->timer, 91 | ]; 92 | } 93 | 94 | public function __unserialize(array $data): void 95 | { 96 | // BC layer for old objects serialized via __sleep 97 | if (5 === \count($data)) { 98 | $data = array_values($data); 99 | $this->id = $data[0]; 100 | $this->tokens = $data[1]; 101 | $this->timer = $data[2]; 102 | $this->burstSize = $data[3]; 103 | $this->rate = Rate::fromString($data[4]); 104 | 105 | return; 106 | } 107 | 108 | [$this->tokens, $this->timer] = array_values($data); 109 | [$pack, $rate] = array_keys($data); 110 | $this->rate = Rate::fromString($rate); 111 | $this->burstSize = unpack('Na', $pack)['a']; 112 | $this->id = substr($pack, 4); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Policy/TokenBucketLimiter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\Lock\LockInterface; 15 | use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; 16 | use Symfony\Component\RateLimiter\LimiterInterface; 17 | use Symfony\Component\RateLimiter\RateLimit; 18 | use Symfony\Component\RateLimiter\Reservation; 19 | use Symfony\Component\RateLimiter\Storage\StorageInterface; 20 | 21 | /** 22 | * @author Wouter de Jong 23 | */ 24 | final class TokenBucketLimiter implements LimiterInterface 25 | { 26 | use ResetLimiterTrait; 27 | 28 | public function __construct( 29 | string $id, 30 | private int $maxBurst, 31 | private Rate $rate, 32 | StorageInterface $storage, 33 | ?LockInterface $lock = null, 34 | ) { 35 | $this->id = $id; 36 | $this->storage = $storage; 37 | $this->lock = $lock; 38 | } 39 | 40 | /** 41 | * Waits until the required number of tokens is available. 42 | * 43 | * The reserved tokens will be taken into account when calculating 44 | * future token consumptions. Do not use this method if you intend 45 | * to skip this process. 46 | * 47 | * @param int $tokens the number of tokens required 48 | * @param float|null $maxTime maximum accepted waiting time in seconds 49 | * 50 | * @throws MaxWaitDurationExceededException if $maxTime is set and the process needs to wait longer than its value (in seconds) 51 | * @throws \InvalidArgumentException if $tokens is larger than the maximum burst size 52 | */ 53 | public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation 54 | { 55 | if ($tokens > $this->maxBurst) { 56 | throw new \InvalidArgumentException(\sprintf('Cannot reserve more tokens (%d) than the burst size of the rate limiter (%d).', $tokens, $this->maxBurst)); 57 | } 58 | 59 | $this->lock?->acquire(true); 60 | 61 | try { 62 | $bucket = $this->storage->fetch($this->id); 63 | if (!$bucket instanceof TokenBucket) { 64 | $bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate); 65 | } 66 | 67 | $now = microtime(true); 68 | $availableTokens = $bucket->getAvailableTokens($now); 69 | 70 | if ($availableTokens > $this->maxBurst) { 71 | $availableTokens = $this->maxBurst; 72 | } 73 | 74 | if ($availableTokens >= $tokens) { 75 | // tokens are now available, update bucket 76 | $bucket->setTokens($availableTokens - $tokens); 77 | 78 | if (0 === $availableTokens) { 79 | // This means 0 tokens where consumed (discouraged in most cases). 80 | // Return the first time a new token is available 81 | $waitDuration = $this->rate->calculateTimeForTokens(1); 82 | $waitTime = \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)); 83 | } else { 84 | $waitTime = \DateTimeImmutable::createFromFormat('U', floor($now)); 85 | } 86 | 87 | $reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), $waitTime, true, $this->maxBurst)); 88 | } else { 89 | $remainingTokens = $tokens - $availableTokens; 90 | $waitDuration = $this->rate->calculateTimeForTokens($remainingTokens); 91 | 92 | if (null !== $maxTime && $waitDuration > $maxTime) { 93 | // process needs to wait longer than set interval 94 | $rateLimit = new RateLimit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst); 95 | 96 | throw new MaxWaitDurationExceededException(\sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $rateLimit); 97 | } 98 | 99 | // at $now + $waitDuration all tokens will be reserved for this process, 100 | // so no tokens are left for other processes. 101 | $bucket->setTokens($availableTokens - $tokens); 102 | 103 | $reservation = new Reservation($now + $waitDuration, new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst)); 104 | } 105 | 106 | if (0 < $tokens) { 107 | $this->storage->save($bucket); 108 | } 109 | } finally { 110 | $this->lock?->release(); 111 | } 112 | 113 | return $reservation; 114 | } 115 | 116 | public function consume(int $tokens = 1): RateLimit 117 | { 118 | try { 119 | return $this->reserve($tokens, 0)->getRateLimit(); 120 | } catch (MaxWaitDurationExceededException $e) { 121 | return $e->getRateLimit(); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Policy/Window.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Policy; 13 | 14 | use Symfony\Component\RateLimiter\LimiterStateInterface; 15 | 16 | /** 17 | * @author Wouter de Jong 18 | * 19 | * @internal 20 | */ 21 | final class Window implements LimiterStateInterface 22 | { 23 | private int $hitCount = 0; 24 | private int $maxSize; 25 | private float $timer; 26 | 27 | public function __construct( 28 | private string $id, 29 | private int $intervalInSeconds, 30 | int $windowSize, 31 | ?float $timer = null, 32 | ) { 33 | $this->maxSize = $windowSize; 34 | $this->timer = $timer ?? microtime(true); 35 | } 36 | 37 | public function getId(): string 38 | { 39 | return $this->id; 40 | } 41 | 42 | public function getExpirationTime(): ?int 43 | { 44 | return $this->intervalInSeconds; 45 | } 46 | 47 | public function add(int $hits = 1, ?float $now = null): void 48 | { 49 | $now ??= microtime(true); 50 | if (($now - $this->timer) > $this->intervalInSeconds) { 51 | // reset window 52 | $this->timer = $now; 53 | $this->hitCount = 0; 54 | } 55 | 56 | $this->hitCount += $hits; 57 | } 58 | 59 | public function getHitCount(): int 60 | { 61 | return $this->hitCount; 62 | } 63 | 64 | public function getAvailableTokens(float $now): int 65 | { 66 | // if now is more than the window interval in the past, all tokens are available 67 | if (($now - $this->timer) > $this->intervalInSeconds) { 68 | return $this->maxSize; 69 | } 70 | 71 | return $this->maxSize - $this->hitCount; 72 | } 73 | 74 | public function calculateTimeForTokens(int $tokens, float $now): int 75 | { 76 | if (($this->maxSize - $this->hitCount) >= $tokens) { 77 | return 0; 78 | } 79 | 80 | return (int) ceil($this->timer + $this->intervalInSeconds - $now); 81 | } 82 | 83 | public function __serialize(): array 84 | { 85 | return [ 86 | $this->id => $this->timer, 87 | pack('NN', $this->hitCount, $this->intervalInSeconds) => $this->maxSize, 88 | ]; 89 | } 90 | 91 | public function __unserialize(array $data): void 92 | { 93 | // BC layer for old objects serialized via __sleep 94 | if (5 === \count($data)) { 95 | $data = array_values($data); 96 | $this->id = $data[0]; 97 | $this->hitCount = $data[1]; 98 | $this->intervalInSeconds = $data[2]; 99 | $this->maxSize = $data[3]; 100 | $this->timer = $data[4]; 101 | 102 | return; 103 | } 104 | 105 | [$this->timer, $this->maxSize] = array_values($data); 106 | [$this->id, $pack] = array_keys($data); 107 | ['a' => $this->hitCount, 'b' => $this->intervalInSeconds] = unpack('Na/Nb', $pack); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rate Limiter Component 2 | ====================== 3 | 4 | The Rate Limiter component provides a Token Bucket implementation to 5 | rate limit input and output in your application. 6 | 7 | Getting Started 8 | --------------- 9 | 10 | ```bash 11 | composer require symfony/rate-limiter 12 | ``` 13 | 14 | ```php 15 | use Symfony\Component\RateLimiter\Storage\InMemoryStorage; 16 | use Symfony\Component\RateLimiter\RateLimiterFactory; 17 | 18 | $factory = new RateLimiterFactory([ 19 | 'id' => 'login', 20 | 'policy' => 'token_bucket', 21 | 'limit' => 10, 22 | 'rate' => ['interval' => '15 minutes'], 23 | ], new InMemoryStorage()); 24 | 25 | $limiter = $factory->create(); 26 | 27 | // blocks until 1 token is free to use for this process 28 | $limiter->reserve(1)->wait(); 29 | // ... execute the code 30 | 31 | // only claims 1 token if it's free at this moment (useful if you plan to skip this process) 32 | if ($limiter->consume(1)->isAccepted()) { 33 | // ... execute the code 34 | } 35 | ``` 36 | 37 | Resources 38 | --------- 39 | 40 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 41 | * [Report issues](https://github.com/symfony/symfony/issues) and 42 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 43 | in the [main Symfony repository](https://github.com/symfony/symfony) 44 | -------------------------------------------------------------------------------- /RateLimit.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | use Symfony\Component\RateLimiter\Exception\RateLimitExceededException; 15 | 16 | /** 17 | * @author Valentin Silvestre 18 | */ 19 | class RateLimit 20 | { 21 | public function __construct( 22 | private int $availableTokens, 23 | private \DateTimeImmutable $retryAfter, 24 | private bool $accepted, 25 | private int $limit, 26 | ) { 27 | } 28 | 29 | public function isAccepted(): bool 30 | { 31 | return $this->accepted; 32 | } 33 | 34 | /** 35 | * @return $this 36 | * 37 | * @throws RateLimitExceededException if not accepted 38 | */ 39 | public function ensureAccepted(): static 40 | { 41 | if (!$this->accepted) { 42 | throw new RateLimitExceededException($this); 43 | } 44 | 45 | return $this; 46 | } 47 | 48 | public function getRetryAfter(): \DateTimeImmutable 49 | { 50 | return $this->retryAfter; 51 | } 52 | 53 | public function getRemainingTokens(): int 54 | { 55 | return $this->availableTokens; 56 | } 57 | 58 | public function getLimit(): int 59 | { 60 | return $this->limit; 61 | } 62 | 63 | public function wait(): void 64 | { 65 | $delta = $this->retryAfter->format('U.u') - microtime(true); 66 | if ($delta <= 0) { 67 | return; 68 | } 69 | 70 | usleep((int) ($delta * 1e6)); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /RateLimiterFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | use Symfony\Component\Lock\LockFactory; 15 | use Symfony\Component\OptionsResolver\Options; 16 | use Symfony\Component\OptionsResolver\OptionsResolver; 17 | use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter; 18 | use Symfony\Component\RateLimiter\Policy\NoLimiter; 19 | use Symfony\Component\RateLimiter\Policy\Rate; 20 | use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter; 21 | use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; 22 | use Symfony\Component\RateLimiter\Storage\StorageInterface; 23 | 24 | /** 25 | * @author Wouter de Jong 26 | */ 27 | final class RateLimiterFactory implements RateLimiterFactoryInterface 28 | { 29 | private array $config; 30 | 31 | public function __construct( 32 | array $config, 33 | private StorageInterface $storage, 34 | private ?LockFactory $lockFactory = null, 35 | ) { 36 | $options = new OptionsResolver(); 37 | self::configureOptions($options); 38 | 39 | $this->config = $options->resolve($config); 40 | } 41 | 42 | public function create(?string $key = null): LimiterInterface 43 | { 44 | $id = $this->config['id'].'-'.$key; 45 | $lock = $this->lockFactory?->createLock($id); 46 | 47 | 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(), 52 | 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'])), 53 | }; 54 | } 55 | 56 | private static function configureOptions(OptionsResolver $options): void 57 | { 58 | $intervalNormalizer = static function (Options $options, string $interval): \DateInterval { 59 | // Create DateTimeImmutable from unix timesatmp, so the default timezone is ignored and we don't need to 60 | // deal with quirks happening when modifying dates using a timezone with DST. 61 | $now = \DateTimeImmutable::createFromFormat('U', time()); 62 | 63 | try { 64 | $nowPlusInterval = @$now->modify('+'.$interval); 65 | } catch (\DateMalformedStringException $e) { 66 | throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e); 67 | } 68 | 69 | if (!$nowPlusInterval) { 70 | throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval)); 71 | } 72 | 73 | return $now->diff($nowPlusInterval); 74 | }; 75 | 76 | $options 77 | ->define('id')->required() 78 | ->define('policy') 79 | ->required() 80 | ->allowedValues('token_bucket', 'fixed_window', 'sliding_window', 'no_limit') 81 | 82 | ->define('limit')->allowedTypes('int') 83 | ->define('interval')->allowedTypes('string')->normalize($intervalNormalizer) 84 | ->define('rate') 85 | ->options(function (OptionsResolver $rate) use ($intervalNormalizer) { 86 | $rate 87 | ->define('amount')->allowedTypes('int')->default(1) 88 | ->define('interval')->allowedTypes('string')->normalize($intervalNormalizer) 89 | ; 90 | }) 91 | ->normalize(function (Options $options, $value) { 92 | if (!isset($value['interval'])) { 93 | return null; 94 | } 95 | 96 | return new Rate($value['interval'], $value['amount']); 97 | }) 98 | ; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /RateLimiterFactoryInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | /** 15 | * @author Alexandre Daubois 16 | */ 17 | interface RateLimiterFactoryInterface 18 | { 19 | /** 20 | * @param string|null $key an optional key used to identify the limiter 21 | */ 22 | public function create(?string $key = null): LimiterInterface; 23 | } 24 | -------------------------------------------------------------------------------- /Reservation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter; 13 | 14 | /** 15 | * @author Wouter de Jong 16 | */ 17 | final class Reservation 18 | { 19 | /** 20 | * @param float $timeToAct Unix timestamp in seconds when this reservation should act 21 | */ 22 | public function __construct( 23 | private float $timeToAct, 24 | private RateLimit $rateLimit, 25 | ) { 26 | } 27 | 28 | public function getTimeToAct(): float 29 | { 30 | return $this->timeToAct; 31 | } 32 | 33 | public function getWaitDuration(): float 34 | { 35 | return max(0, (-microtime(true)) + $this->timeToAct); 36 | } 37 | 38 | public function getRateLimit(): RateLimit 39 | { 40 | return $this->rateLimit; 41 | } 42 | 43 | public function wait(): void 44 | { 45 | usleep((int) ($this->getWaitDuration() * 1e6)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Storage/CacheStorage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Storage; 13 | 14 | use Psr\Cache\CacheItemPoolInterface; 15 | use Symfony\Component\RateLimiter\LimiterStateInterface; 16 | 17 | /** 18 | * @author Wouter de Jong 19 | */ 20 | class CacheStorage implements StorageInterface 21 | { 22 | public function __construct( 23 | private CacheItemPoolInterface $pool, 24 | ) { 25 | } 26 | 27 | public function save(LimiterStateInterface $limiterState): void 28 | { 29 | $cacheItem = $this->pool->getItem(sha1($limiterState->getId())); 30 | $cacheItem->set($limiterState); 31 | if (null !== ($expireAfter = $limiterState->getExpirationTime())) { 32 | $cacheItem->expiresAfter($expireAfter); 33 | } 34 | 35 | $this->pool->save($cacheItem); 36 | } 37 | 38 | public function fetch(string $limiterStateId): ?LimiterStateInterface 39 | { 40 | $cacheItem = $this->pool->getItem(sha1($limiterStateId)); 41 | $value = $cacheItem->get(); 42 | if ($value instanceof LimiterStateInterface) { 43 | return $value; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | public function delete(string $limiterStateId): void 50 | { 51 | $this->pool->deleteItem(sha1($limiterStateId)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Storage/InMemoryStorage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Storage; 13 | 14 | use Symfony\Component\RateLimiter\LimiterStateInterface; 15 | 16 | /** 17 | * @author Wouter de Jong 18 | */ 19 | class InMemoryStorage implements StorageInterface 20 | { 21 | private array $buckets = []; 22 | 23 | public function save(LimiterStateInterface $limiterState): void 24 | { 25 | $this->buckets[$limiterState->getId()] = [$this->getExpireAt($limiterState), serialize($limiterState)]; 26 | } 27 | 28 | public function fetch(string $limiterStateId): ?LimiterStateInterface 29 | { 30 | if (!isset($this->buckets[$limiterStateId])) { 31 | return null; 32 | } 33 | 34 | [$expireAt, $limiterState] = $this->buckets[$limiterStateId]; 35 | if (null !== $expireAt && $expireAt <= microtime(true)) { 36 | unset($this->buckets[$limiterStateId]); 37 | 38 | return null; 39 | } 40 | 41 | return unserialize($limiterState); 42 | } 43 | 44 | public function delete(string $limiterStateId): void 45 | { 46 | if (!isset($this->buckets[$limiterStateId])) { 47 | return; 48 | } 49 | 50 | unset($this->buckets[$limiterStateId]); 51 | } 52 | 53 | private function getExpireAt(LimiterStateInterface $limiterState): ?float 54 | { 55 | if (null !== $expireSeconds = $limiterState->getExpirationTime()) { 56 | return microtime(true) + $expireSeconds; 57 | } 58 | 59 | return $this->buckets[$limiterState->getId()][0] ?? null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Storage/StorageInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Storage; 13 | 14 | use Symfony\Component\RateLimiter\LimiterStateInterface; 15 | 16 | /** 17 | * @author Wouter de Jong 18 | */ 19 | interface StorageInterface 20 | { 21 | public function save(LimiterStateInterface $limiterState): void; 22 | 23 | public function fetch(string $limiterStateId): ?LimiterStateInterface; 24 | 25 | public function delete(string $limiterStateId): void; 26 | } 27 | -------------------------------------------------------------------------------- /Util/TimeUtil.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\RateLimiter\Util; 13 | 14 | /** 15 | * @author Wouter de Jong 16 | * 17 | * @internal 18 | */ 19 | final class TimeUtil 20 | { 21 | public static function dateIntervalToSeconds(\DateInterval $interval): int 22 | { 23 | $now = \DateTimeImmutable::createFromFormat('U', time()); 24 | 25 | return $now->add($interval)->getTimestamp() - $now->getTimestamp(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/rate-limiter", 3 | "type": "library", 4 | "description": "Provides a Token Bucket implementation to rate limit input and output in your application", 5 | "keywords": ["limiter", "rate-limiter"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Wouter de Jong", 11 | "email": "wouter@wouterj.nl" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "symfony/options-resolver": "^7.3" 21 | }, 22 | "require-dev": { 23 | "psr/cache": "^1.0|^2.0|^3.0", 24 | "symfony/lock": "^6.4|^7.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { "Symfony\\Component\\RateLimiter\\": "" }, 28 | "exclude-from-classmap": [ 29 | "/Tests/" 30 | ] 31 | }, 32 | "minimum-stability": "dev" 33 | } 34 | --------------------------------------------------------------------------------