diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2c92a1a748..d4d377be32 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -128,8 +128,8 @@ use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; -use Symfony\Component\RateLimiter\Limiter; use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiter; use Symfony\Component\RateLimiter\Storage\CacheStorage; use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader; use Symfony\Component\Routing\Loader\AnnotationFileLoader; @@ -2266,7 +2266,7 @@ class FrameworkExtension extends Extension $limiterConfig['id'] = $name; $limiter->replaceArgument(0, $limiterConfig); - $container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter'); + $container->registerAliasForArgument($limiterId, RateLimiter::class, $name.'.limiter'); } private function resolveTrustedHeaders(array $headers): int diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php index 104b802379..e9677ae962 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php @@ -11,7 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\RateLimiter\RateLimiter; return static function (ContainerConfigurator $container) { $container->services() @@ -19,7 +19,7 @@ return static function (ContainerConfigurator $container) { ->parent('cache.app') ->tag('cache.pool') - ->set('limiter', Limiter::class) + ->set('limiter', RateLimiter::class) ->abstract() ->args([ abstract_arg('config'), diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php index df30cd0578..5e08a445c9 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LoginThrottlingFactory.php @@ -18,7 +18,7 @@ use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface; -use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\RateLimiter\RateLimiter; use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener; use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter; @@ -63,7 +63,7 @@ class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityF throw new \LogicException('Login throttling requires symfony/security-http:^5.2.'); } - if (!class_exists(Limiter::class)) { + if (!class_exists(RateLimiter::class)) { throw new \LogicException('Login throttling requires symfony/rate-limiter to be installed and enabled.'); } diff --git a/src/Symfony/Component/RateLimiter/CompoundLimiter.php b/src/Symfony/Component/RateLimiter/CompoundLimiter.php index e2a287685b..6722e0b436 100644 --- a/src/Symfony/Component/RateLimiter/CompoundLimiter.php +++ b/src/Symfony/Component/RateLimiter/CompoundLimiter.php @@ -11,6 +11,8 @@ namespace Symfony\Component\RateLimiter; +use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; + /** * @author Wouter de Jong * @@ -31,6 +33,11 @@ final class CompoundLimiter implements LimiterInterface $this->limiters = $limiters; } + public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation + { + throw new ReserveNotSupportedException(__CLASS__); + } + public function consume(int $tokens = 1): Limit { $minimalLimit = null; diff --git a/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php b/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php index 4e4e7fcaac..025103f7a8 100644 --- a/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php +++ b/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php @@ -11,6 +11,8 @@ namespace Symfony\Component\RateLimiter\Exception; +use Symfony\Component\RateLimiter\Limit; + /** * @author Wouter de Jong * @@ -18,4 +20,17 @@ namespace Symfony\Component\RateLimiter\Exception; */ class MaxWaitDurationExceededException extends \RuntimeException { + private $limit; + + public function __construct(string $message, Limit $limit, int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $this->limit = $limit; + } + + public function getLimit(): Limit + { + return $this->limit; + } } diff --git a/src/Symfony/Component/RateLimiter/Exception/ReserveNotSupportedException.php b/src/Symfony/Component/RateLimiter/Exception/ReserveNotSupportedException.php new file mode 100644 index 0000000000..852c99ae1d --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Exception/ReserveNotSupportedException.php @@ -0,0 +1,25 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +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); + } +} diff --git a/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php b/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php index 1e5c2a46f3..9164eb4ae5 100644 --- a/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php @@ -13,6 +13,7 @@ namespace Symfony\Component\RateLimiter; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Lock\NoLock; +use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; use Symfony\Component\RateLimiter\Storage\StorageInterface; use Symfony\Component\RateLimiter\Util\TimeUtil; @@ -33,6 +34,10 @@ final class FixedWindowLimiter implements LimiterInterface 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 ?? new NoLock(); $this->id = $id; @@ -40,42 +45,61 @@ final class FixedWindowLimiter implements LimiterInterface $this->interval = TimeUtil::dateIntervalToSeconds($interval); } - /** - * {@inheritdoc} - */ - public function consume(int $tokens = 1): Limit + 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); + $window = new Window($this->id, $this->interval, $this->limit); } - $hitCount = $window->getHitCount(); - $availableTokens = $this->getAvailableTokens($hitCount); - $windowStart = \DateTimeImmutable::createFromFormat('U', time()); - if ($availableTokens < $tokens) { - return new Limit($availableTokens, $this->getRetryAfter($windowStart), false); - } + $now = microtime(true); + $availableTokens = $window->getAvailableTokens($now); + if ($availableTokens >= $tokens) { + $window->add($tokens); - $window->add($tokens); + $reservation = new Reservation($now, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true)); + } else { + $remainingTokens = $tokens - $availableTokens; + $waitDuration = $window->calculateTimeForTokens($remainingTokens); + + 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 Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false)); + } + + $window->add($tokens); + + $reservation = new Reservation($now + $waitDuration, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false)); + } $this->storage->save($window); - - return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true); } finally { $this->lock->release(); } + + return $reservation; + } + + /** + * {@inheritdoc} + */ + public function consume(int $tokens = 1): Limit + { + try { + return $this->reserve($tokens, 0)->getLimit(); + } catch (MaxWaitDurationExceededException $e) { + return $e->getLimit(); + } } public function getAvailableTokens(int $hitCount): int { return $this->limit - $hitCount; } - - private function getRetryAfter(\DateTimeImmutable $windowStart): \DateTimeImmutable - { - return $windowStart->add(new \DateInterval(sprintf('PT%sS', $this->interval))); - } } diff --git a/src/Symfony/Component/RateLimiter/LimiterInterface.php b/src/Symfony/Component/RateLimiter/LimiterInterface.php index d768081594..e04a7ea26b 100644 --- a/src/Symfony/Component/RateLimiter/LimiterInterface.php +++ b/src/Symfony/Component/RateLimiter/LimiterInterface.php @@ -11,6 +11,9 @@ namespace Symfony\Component\RateLimiter; +use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; +use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; + /** * @author Wouter de Jong * @@ -18,6 +21,22 @@ namespace Symfony\Component\RateLimiter; */ 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 $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. diff --git a/src/Symfony/Component/RateLimiter/NoLimiter.php b/src/Symfony/Component/RateLimiter/NoLimiter.php index b5f5d5f68c..65f772a66e 100644 --- a/src/Symfony/Component/RateLimiter/NoLimiter.php +++ b/src/Symfony/Component/RateLimiter/NoLimiter.php @@ -23,6 +23,11 @@ namespace Symfony\Component\RateLimiter; */ final class NoLimiter implements LimiterInterface { + public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation + { + return new Reservation(time(), new Limit(\INF, new \DateTimeImmutable(), true)); + } + public function consume(int $tokens = 1): Limit { return new Limit(\INF, new \DateTimeImmutable(), true); diff --git a/src/Symfony/Component/RateLimiter/README.md b/src/Symfony/Component/RateLimiter/README.md index 0d4ff465e3..18e91b9e09 100644 --- a/src/Symfony/Component/RateLimiter/README.md +++ b/src/Symfony/Component/RateLimiter/README.md @@ -18,9 +18,9 @@ $ composer require symfony/rate-limiter ```php use Symfony\Component\RateLimiter\Storage\InMemoryStorage; -use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\RateLimiter\RateLimiter; -$limiter = new Limiter([ +$limiter = new RateLimiter([ 'id' => 'login', 'strategy' => 'token_bucket', // or 'fixed_window' 'limit' => 10, diff --git a/src/Symfony/Component/RateLimiter/Limiter.php b/src/Symfony/Component/RateLimiter/RateLimiter.php similarity index 99% rename from src/Symfony/Component/RateLimiter/Limiter.php rename to src/Symfony/Component/RateLimiter/RateLimiter.php index 61018ab807..45edecd877 100644 --- a/src/Symfony/Component/RateLimiter/Limiter.php +++ b/src/Symfony/Component/RateLimiter/RateLimiter.php @@ -22,7 +22,7 @@ use Symfony\Component\RateLimiter\Storage\StorageInterface; * * @experimental in 5.2 */ -final class Limiter +final class RateLimiter { private $config; private $storage; diff --git a/src/Symfony/Component/RateLimiter/Reservation.php b/src/Symfony/Component/RateLimiter/Reservation.php index fc33c5ad0f..26a1d1750d 100644 --- a/src/Symfony/Component/RateLimiter/Reservation.php +++ b/src/Symfony/Component/RateLimiter/Reservation.php @@ -19,13 +19,15 @@ namespace Symfony\Component\RateLimiter; final class Reservation { private $timeToAct; + private $limit; /** * @param float $timeToAct Unix timestamp in seconds when this reservation should act */ - public function __construct(float $timeToAct) + public function __construct(float $timeToAct, Limit $limit) { $this->timeToAct = $timeToAct; + $this->limit = $limit; } public function getTimeToAct(): float @@ -38,6 +40,11 @@ final class Reservation return max(0, (-microtime(true)) + $this->timeToAct); } + public function getLimit(): Limit + { + return $this->limit; + } + public function wait(): void { usleep($this->getWaitDuration() * 1e6); diff --git a/src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php b/src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php index fbc6a41958..dcdf619843 100644 --- a/src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php @@ -13,6 +13,7 @@ namespace Symfony\Component\RateLimiter; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Lock\NoLock; +use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; use Symfony\Component\RateLimiter\Storage\StorageInterface; use Symfony\Component\RateLimiter\Util\TimeUtil; @@ -67,6 +68,11 @@ final class SlidingWindowLimiter implements LimiterInterface $this->interval = TimeUtil::dateIntervalToSeconds($interval); } + public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation + { + throw new ReserveNotSupportedException(__CLASS__); + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php index aab06fff39..fb28996f20 100644 --- a/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php @@ -14,6 +14,7 @@ namespace Symfony\Component\RateLimiter\Tests; use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ClockMock; use Symfony\Component\RateLimiter\CompoundLimiter; +use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; use Symfony\Component\RateLimiter\FixedWindowLimiter; use Symfony\Component\RateLimiter\Storage\InMemoryStorage; @@ -38,22 +39,26 @@ class CompoundLimiterTest extends TestCase $limiter3 = $this->createLimiter(12, new \DateInterval('PT30S')); $limiter = new CompoundLimiter([$limiter1, $limiter2, $limiter3]); - // Reach limiter 1 limit, verify that limiter2 available tokens reduced by 5 and fetch successfully limiter 1 - $this->assertEquals(3, $limiter->consume(5)->getRemainingTokens(), 'Limiter 1 reached the limit'); + $this->assertEquals(0, $limiter->consume(4)->getRemainingTokens(), 'Limiter 1 reached the limit'); sleep(1); // reset limiter1's window - $this->assertTrue($limiter->consume(2)->isAccepted()); - - // Reach limiter 2 limit, verify that limiter2 available tokens reduced by 5 and and fetch successfully - $this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 2 has no remaining tokens left'); - sleep(9); // reset limiter2's window $this->assertTrue($limiter->consume(3)->isAccepted()); - // Reach limiter 3 limit, verify that limiter2 available tokens reduced by 5 and fetch successfully + $this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 2 has no remaining tokens left'); + sleep(10); // reset limiter2's window + $this->assertTrue($limiter->consume(3)->isAccepted()); + $this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 3 reached the limit'); sleep(20); // reset limiter3's window $this->assertTrue($limiter->consume()->isAccepted()); } + public function testReserve() + { + $this->expectException(ReserveNotSupportedException::class); + + (new CompoundLimiter([$this->createLimiter(4, new \DateInterval('PT1S'))]))->reserve(); + } + private function createLimiter(int $limit, \DateInterval $interval): FixedWindowLimiter { return new FixedWindowLimiter('test'.$limit, $limit, $interval, $this->storage); diff --git a/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php index 819e748fd5..a6f436616f 100644 --- a/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php @@ -60,7 +60,7 @@ class FixedWindowLimiterTest extends TestCase sleep(10); $limit = $limiter->consume(10); $this->assertEquals(0, $limit->getRemainingTokens()); - $this->assertEquals(time() + 60, $limit->getRetryAfter()->getTimestamp()); + $this->assertTrue($limit->isAccepted()); } public function testWrongWindowFromCache() diff --git a/src/Symfony/Component/RateLimiter/Tests/LimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/LimiterTest.php index 8d1442f280..c464e1a7e2 100644 --- a/src/Symfony/Component/RateLimiter/Tests/LimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/LimiterTest.php @@ -14,7 +14,7 @@ namespace Symfony\Component\RateLimiter\Tests; use PHPUnit\Framework\TestCase; use Symfony\Component\Lock\LockFactory; use Symfony\Component\RateLimiter\FixedWindowLimiter; -use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\RateLimiter\RateLimiter; use Symfony\Component\RateLimiter\Storage\StorageInterface; use Symfony\Component\RateLimiter\TokenBucketLimiter; @@ -61,6 +61,6 @@ class LimiterTest extends TestCase private function createFactory(array $options) { - return new Limiter($options, $this->createMock(StorageInterface::class), $this->createMock(LockFactory::class)); + return new RateLimiter($options, $this->createMock(StorageInterface::class), $this->createMock(LockFactory::class)); } } diff --git a/src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php index 589d4e5550..341f216c29 100644 --- a/src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/SlidingWindowLimiterTest.php @@ -13,6 +13,7 @@ namespace Symfony\Component\RateLimiter\Tests; use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException; use Symfony\Component\RateLimiter\SlidingWindowLimiter; use Symfony\Component\RateLimiter\Storage\InMemoryStorage; @@ -50,6 +51,13 @@ class SlidingWindowLimiterTest extends TestCase $this->assertTrue($limit->isAccepted()); } + public function testReserve() + { + $this->expectException(ReserveNotSupportedException::class); + + $this->createLimiter()->reserve(); + } + private function createLimiter(): SlidingWindowLimiter { return new SlidingWindowLimiter('test', 10, new \DateInterval('PT12S'), $this->storage); diff --git a/src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php b/src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php index a7baae6c88..de8f77b463 100644 --- a/src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php @@ -31,15 +31,14 @@ class CacheStorageTest extends TestCase public function testSave() { $cacheItem = $this->createMock(CacheItemInterface::class); - $cacheItem->expects($this->once())->method('expiresAfter')->with(10); + $cacheItem->expects($this->exactly(2))->method('expiresAfter')->with(10); $this->pool->expects($this->any())->method('getItem')->with(sha1('test'))->willReturn($cacheItem); $this->pool->expects($this->exactly(2))->method('save')->with($cacheItem); - $window = new Window('test', 10); + $window = new Window('test', 10, 20); $this->storage->save($window); - // test that expiresAfter is only called when getExpirationAt() does not return null $window = unserialize(serialize($window)); $this->storage->save($window); } @@ -47,7 +46,7 @@ class CacheStorageTest extends TestCase public function testFetchExistingState() { $cacheItem = $this->createMock(CacheItemInterface::class); - $window = new Window('test', 10); + $window = new Window('test', 10, 20); $cacheItem->expects($this->any())->method('get')->willReturn($window); $cacheItem->expects($this->any())->method('isHit')->willReturn(true); diff --git a/src/Symfony/Component/RateLimiter/TokenBucket.php b/src/Symfony/Component/RateLimiter/TokenBucket.php index 01de558f35..c7fbcb5ebc 100644 --- a/src/Symfony/Component/RateLimiter/TokenBucket.php +++ b/src/Symfony/Component/RateLimiter/TokenBucket.php @@ -14,6 +14,7 @@ namespace Symfony\Component\RateLimiter; /** * @author Wouter de Jong * + * @internal * @experimental in 5.2 */ final class TokenBucket implements LimiterStateInterface @@ -32,6 +33,10 @@ final class TokenBucket implements LimiterStateInterface */ 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; diff --git a/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php b/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php index 05e5bb893b..b8997ac7e0 100644 --- a/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php +++ b/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php @@ -74,14 +74,16 @@ final class TokenBucketLimiter implements LimiterInterface $bucket->setTokens($availableTokens - $tokens); $bucket->setTimer($now); - $reservation = new Reservation($now); + $reservation = new Reservation($now, new Limit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true)); } else { $remainingTokens = $tokens - $availableTokens; $waitDuration = $this->rate->calculateTimeForTokens($remainingTokens); 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)); + $limit = new Limit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false); + + throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $limit); } // at $now + $waitDuration all tokens will be reserved for this process, @@ -89,7 +91,7 @@ final class TokenBucketLimiter implements LimiterInterface $bucket->setTokens(0); $bucket->setTimer($now + $waitDuration); - $reservation = new Reservation($bucket->getTimer()); + $reservation = new Reservation($bucket->getTimer(), new Limit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false)); } $this->storage->save($bucket); @@ -105,18 +107,10 @@ final class TokenBucketLimiter implements LimiterInterface */ public function consume(int $tokens = 1): Limit { - $bucket = $this->storage->fetch($this->id); - if (!$bucket instanceof TokenBucket) { - $bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate); - } - $now = microtime(true); - try { - $this->reserve($tokens, 0); - - return new Limit($bucket->getAvailableTokens($now) - $tokens, $this->rate->calculateNextTokenAvailability(), true); + return $this->reserve($tokens, 0)->getLimit(); } catch (MaxWaitDurationExceededException $e) { - return new Limit($bucket->getAvailableTokens($now), $this->rate->calculateNextTokenAvailability(), false); + return $e->getLimit(); } } } diff --git a/src/Symfony/Component/RateLimiter/Window.php b/src/Symfony/Component/RateLimiter/Window.php index e7966aa5fe..46ca1b3820 100644 --- a/src/Symfony/Component/RateLimiter/Window.php +++ b/src/Symfony/Component/RateLimiter/Window.php @@ -14,6 +14,7 @@ namespace Symfony\Component\RateLimiter; /** * @author Wouter de Jong * + * @internal * @experimental in 5.2 */ final class Window implements LimiterStateInterface @@ -21,11 +22,15 @@ final class Window implements LimiterStateInterface private $id; private $hitCount = 0; private $intervalInSeconds; + private $maxSize; + private $timer; - public function __construct(string $id, int $intervalInSeconds) + 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 @@ -38,8 +43,15 @@ final class Window implements LimiterStateInterface return $this->intervalInSeconds; } - public function add(int $hits = 1) + public function add(int $hits = 1, ?float $now = null) { + $now = $now ?? microtime(true); + if (($now - $this->timer) > $this->intervalInSeconds) { + // reset window + $this->timer = $now; + $this->hitCount = 0; + } + $this->hitCount += $hits; } @@ -48,13 +60,37 @@ final class Window implements LimiterStateInterface return $this->hitCount; } + public function getAvailableTokens(float $now) + { + // if timer is in future, there are no tokens available anymore + if ($this->timer > $now) { + return 0; + } + + // 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): int + { + if (($this->maxSize - $this->hitCount) >= $tokens) { + return 0; + } + + $cyclesRequired = ceil($tokens / $this->maxSize); + + return $cyclesRequired * $this->intervalInSeconds; + } + /** * @internal */ public function __sleep(): array { - // $intervalInSeconds is not serialized, it should only be set - // upon first creation of the Window. - return ['id', 'hitCount']; + return ['id', 'hitCount', 'intervalInSeconds', 'timer', 'maxSize']; } } diff --git a/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php b/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php index bf7735bc36..f5b69674e7 100644 --- a/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php +++ b/src/Symfony/Component/Security/Http/RateLimiter/DefaultLoginRateLimiter.php @@ -13,7 +13,7 @@ namespace Symfony\Component\Security\Http\RateLimiter; use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\RateLimiter\RateLimiter; use Symfony\Component\Security\Core\Security; /** @@ -31,7 +31,7 @@ final class DefaultLoginRateLimiter extends AbstractRequestRateLimiter private $globalLimiter; private $localLimiter; - public function __construct(Limiter $globalLimiter, Limiter $localLimiter) + public function __construct(RateLimiter $globalLimiter, RateLimiter $localLimiter) { $this->globalLimiter = $globalLimiter; $this->localLimiter = $localLimiter; diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php index 28af3713fc..4425afa8b0 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/LoginThrottlingListenerTest.php @@ -14,7 +14,7 @@ namespace Symfony\Component\Security\Http\Tests\EventListener; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\RateLimiter\Limiter; +use Symfony\Component\RateLimiter\RateLimiter; use Symfony\Component\RateLimiter\Storage\InMemoryStorage; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException; @@ -35,13 +35,13 @@ class LoginThrottlingListenerTest extends TestCase { $this->requestStack = new RequestStack(); - $localLimiter = new Limiter([ + $localLimiter = new RateLimiter([ 'id' => 'login', 'strategy' => 'fixed_window', 'limit' => 3, 'interval' => '1 minute', ], new InMemoryStorage()); - $globalLimiter = new Limiter([ + $globalLimiter = new RateLimiter([ 'id' => 'login', 'strategy' => 'fixed_window', 'limit' => 6,