From 67417a693e93f09ae0dc6aae842c7d9dcdd22878 Mon Sep 17 00:00:00 2001 From: Wouter de Jong Date: Sat, 4 Jul 2020 21:19:14 +0200 Subject: [PATCH] [RFC] Introduce a RateLimiter component --- .../DependencyInjection/Configuration.php | 48 ++++++++ .../FrameworkExtension.php | 56 ++++++++- .../Resources/config/rate_limiter.php | 25 ++++ .../Resources/config/schema/symfony-1.0.xsd | 27 ++++ .../DependencyInjection/ConfigurationTest.php | 4 + src/Symfony/Component/Lock/CHANGELOG.md | 7 +- src/Symfony/Component/Lock/NoLock.php | 51 ++++++++ .../Component/RateLimiter/.gitattributes | 4 + src/Symfony/Component/RateLimiter/.gitignore | 3 + .../Component/RateLimiter/CHANGELOG.md | 7 ++ .../Component/RateLimiter/CompoundLimiter.php | 47 +++++++ .../MaxWaitDurationExceededException.php | 21 ++++ .../RateLimiter/FixedWindowLimiter.php | 70 +++++++++++ src/Symfony/Component/RateLimiter/LICENSE | 19 +++ src/Symfony/Component/RateLimiter/Limiter.php | 97 +++++++++++++++ .../RateLimiter/LimiterInterface.php | 33 +++++ .../RateLimiter/LimiterStateInterface.php | 24 ++++ .../Component/RateLimiter/NoLimiter.php | 34 +++++ src/Symfony/Component/RateLimiter/README.md | 46 +++++++ src/Symfony/Component/RateLimiter/Rate.php | 92 ++++++++++++++ .../Component/RateLimiter/Reservation.php | 45 +++++++ .../RateLimiter/ResetLimiterTrait.php | 47 +++++++ .../RateLimiter/Storage/CacheStorage.php | 56 +++++++++ .../RateLimiter/Storage/InMemoryStorage.php | 62 ++++++++++ .../RateLimiter/Storage/StorageInterface.php | 28 +++++ .../RateLimiter/Tests/CompoundLimiterTest.php | 60 +++++++++ .../Tests/FixedWindowLimiterTest.php | 65 ++++++++++ .../RateLimiter/Tests/LimiterTest.php | 66 ++++++++++ .../Tests/Storage/CacheStorageTest.php | 68 ++++++++++ .../Tests/TokenBucketLimiterTest.php | 85 +++++++++++++ .../Component/RateLimiter/TokenBucket.php | 84 +++++++++++++ .../RateLimiter/TokenBucketLimiter.php | 116 ++++++++++++++++++ .../Component/RateLimiter/Util/TimeUtil.php | 29 +++++ src/Symfony/Component/RateLimiter/Window.php | 62 ++++++++++ .../Component/RateLimiter/composer.json | 38 ++++++ .../Component/RateLimiter/phpunit.xml.dist | 30 +++++ 36 files changed, 1652 insertions(+), 4 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php create mode 100644 src/Symfony/Component/Lock/NoLock.php create mode 100644 src/Symfony/Component/RateLimiter/.gitattributes create mode 100644 src/Symfony/Component/RateLimiter/.gitignore create mode 100644 src/Symfony/Component/RateLimiter/CHANGELOG.md create mode 100644 src/Symfony/Component/RateLimiter/CompoundLimiter.php create mode 100644 src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php create mode 100644 src/Symfony/Component/RateLimiter/FixedWindowLimiter.php create mode 100644 src/Symfony/Component/RateLimiter/LICENSE create mode 100644 src/Symfony/Component/RateLimiter/Limiter.php create mode 100644 src/Symfony/Component/RateLimiter/LimiterInterface.php create mode 100644 src/Symfony/Component/RateLimiter/LimiterStateInterface.php create mode 100644 src/Symfony/Component/RateLimiter/NoLimiter.php create mode 100644 src/Symfony/Component/RateLimiter/README.md create mode 100644 src/Symfony/Component/RateLimiter/Rate.php create mode 100644 src/Symfony/Component/RateLimiter/Reservation.php create mode 100644 src/Symfony/Component/RateLimiter/ResetLimiterTrait.php create mode 100644 src/Symfony/Component/RateLimiter/Storage/CacheStorage.php create mode 100644 src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php create mode 100644 src/Symfony/Component/RateLimiter/Storage/StorageInterface.php create mode 100644 src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php create mode 100644 src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php create mode 100644 src/Symfony/Component/RateLimiter/Tests/LimiterTest.php create mode 100644 src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php create mode 100644 src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php create mode 100644 src/Symfony/Component/RateLimiter/TokenBucket.php create mode 100644 src/Symfony/Component/RateLimiter/TokenBucketLimiter.php create mode 100644 src/Symfony/Component/RateLimiter/Util/TimeUtil.php create mode 100644 src/Symfony/Component/RateLimiter/Window.php create mode 100644 src/Symfony/Component/RateLimiter/composer.json create mode 100644 src/Symfony/Component/RateLimiter/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 8cb4b2803e758..ea75fdfebfd55 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -30,6 +30,7 @@ use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Notifier\Notifier; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\RateLimiter\TokenBucketLimiter; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Validation; @@ -134,6 +135,7 @@ public function getConfigTreeBuilder() $this->addMailerSection($rootNode); $this->addSecretsSection($rootNode); $this->addNotifierSection($rootNode); + $this->addRateLimiterSection($rootNode); return $treeBuilder; } @@ -1707,4 +1709,50 @@ private function addNotifierSection(ArrayNodeDefinition $rootNode) ->end() ; } + + private function addRateLimiterSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('rate_limiter') + ->info('Rate limiter configuration') + ->{!class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->fixXmlConfig('limiter') + ->beforeNormalization() + ->ifTrue(function ($v) { return \is_array($v) && !isset($v['limiters']) && !isset($v['limiter']); }) + ->then(function (array $v) { + $newV = [ + 'enabled' => $v['enabled'], + ]; + unset($v['enabled']); + + $newV['limiters'] = $v; + + return $newV; + }) + ->end() + ->children() + ->arrayNode('limiters') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->scalarNode('lock')->defaultValue('lock.factory')->end() + ->scalarNode('storage')->defaultValue('cache.app')->end() + ->scalarNode('strategy')->isRequired()->end() + ->integerNode('limit')->isRequired()->end() + ->scalarNode('interval')->end() + ->arrayNode('rate') + ->children() + ->scalarNode('interval')->isRequired()->end() + ->integerNode('amount')->defaultValue(1)->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index e375b3c555528..acffba00fe7e6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -123,6 +123,9 @@ 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\Storage\CacheStorage; use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader; use Symfony\Component\Routing\Loader\AnnotationFileLoader; use Symfony\Component\Security\Core\Security; @@ -173,6 +176,7 @@ class FrameworkExtension extends Extension private $mailerConfigEnabled = false; private $httpClientConfigEnabled = false; private $notifierConfigEnabled = false; + private $lockConfigEnabled = false; /** * Responds to the app.config configuration parameter. @@ -405,10 +409,18 @@ public function load(array $configs, ContainerBuilder $container) $this->registerPropertyInfoConfiguration($container, $loader); } - if ($this->isConfigEnabled($container, $config['lock'])) { + if ($this->lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) { $this->registerLockConfiguration($config['lock'], $container, $loader); } + if ($this->isConfigEnabled($container, $config['rate_limiter'])) { + if (!interface_exists(LimiterInterface::class)) { + throw new LogicException('Rate limiter support cannot be enabled as the RateLimiter component is not installed. Try running "composer require symfony/rate-limiter".'); + } + + $this->registerRateLimiterConfiguration($config['rate_limiter'], $container, $loader); + } + if ($this->isConfigEnabled($container, $config['web_link'])) { if (!class_exists(HttpHeaderSerializer::class)) { throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed. Try running "composer require symfony/weblink".'); @@ -2170,6 +2182,48 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ } } + private function registerRateLimiterConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) + { + if (!$this->lockConfigEnabled) { + throw new LogicException('Rate limiter support cannot be enabled without enabling the Lock component.'); + } + + $loader->load('rate_limiter.php'); + + $locks = []; + $storages = []; + foreach ($config['limiters'] as $name => $limiterConfig) { + $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')); + + if (!isset($locks[$limiterConfig['lock']])) { + $locks[$limiterConfig['lock']] = new Reference($limiterConfig['lock']); + } + $limiter->addArgument($locks[$limiterConfig['lock']]); + unset($limiterConfig['lock']); + + if (!isset($storages[$limiterConfig['storage']])) { + $storageId = $limiterConfig['storage']; + // cache pools are configured by the FrameworkBundle, so they + // exists in the scoped ContainerBuilder provided to this method + if ($container->has($storageId)) { + if ($container->findDefinition($storageId)->hasTag('cache.pool')) { + $container->register('limiter.storage.'.$storageId, CacheStorage::class)->addArgument(new Reference($storageId)); + $storageId = 'limiter.storage.'.$storageId; + } + } + + $storages[$limiterConfig['storage']] = new Reference($storageId); + } + $limiter->replaceArgument(1, $storages[$limiterConfig['storage']]); + unset($limiterConfig['storage']); + + $limiterConfig['id'] = $name; + $limiter->replaceArgument(0, $limiterConfig); + + $container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter'); + } + } + private function resolveTrustedHeaders(array $headers): int { $trustedHeaders = 0; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.php new file mode 100644 index 0000000000000..d249a5603107a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/rate_limiter.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\DependencyInjection\Loader\Configurator; + +use Symfony\Component\RateLimiter\Limiter; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('limiter', Limiter::class) + ->abstract() + ->args([ + abstract_arg('config'), + abstract_arg('storage'), + ]) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 3f5c803baaa17..cdc57ea30e852 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -34,6 +34,7 @@ + @@ -634,4 +635,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 2c7920214ccd1..00afdfd00055f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -531,6 +531,10 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'debug' => '%kernel.debug%', 'private_headers' => [], ], + 'rate_limiter' => [ + 'enabled' => false, + 'limiters' => [], + ], ]; } } diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md index d61ba7f288f69..3eca7127eb4a4 100644 --- a/src/Symfony/Component/Lock/CHANGELOG.md +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * `MongoDbStore` does not implement `BlockingStoreInterface` anymore, typehint against `PersistingStoreInterface` instead. * added support for shared locks + * added `NoLock` 5.1.0 ----- @@ -25,10 +26,10 @@ CHANGELOG * added InvalidTtlException * deprecated `StoreInterface` in favor of `BlockingStoreInterface` and `PersistingStoreInterface` * `Factory` is deprecated, use `LockFactory` instead - * `StoreFactory::createStore` allows PDO and Zookeeper DSN. - * deprecated services `lock.store.flock`, `lock.store.semaphore`, `lock.store.memcached.abstract` and `lock.store.redis.abstract`, + * `StoreFactory::createStore` allows PDO and Zookeeper DSN. + * deprecated services `lock.store.flock`, `lock.store.semaphore`, `lock.store.memcached.abstract` and `lock.store.redis.abstract`, use `StoreFactory::createStore` instead. - + 4.2.0 ----- diff --git a/src/Symfony/Component/Lock/NoLock.php b/src/Symfony/Component/Lock/NoLock.php new file mode 100644 index 0000000000000..074c6c3bdaef1 --- /dev/null +++ b/src/Symfony/Component/Lock/NoLock.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock; + +/** + * A non locking lock. + * + * This can be used to disable locking in classes + * requiring a lock. + * + * @author Wouter de Jong + */ +final class NoLock implements LockInterface +{ + public function acquire(bool $blocking = false): bool + { + return true; + } + + public function refresh(float $ttl = null) + { + } + + public function isAcquired(): bool + { + return true; + } + + public function release() + { + } + + public function isExpired(): bool + { + return false; + } + + public function getRemainingLifetime(): ?float + { + return null; + } +} diff --git a/src/Symfony/Component/RateLimiter/.gitattributes b/src/Symfony/Component/RateLimiter/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/RateLimiter/.gitignore b/src/Symfony/Component/RateLimiter/.gitignore new file mode 100644 index 0000000000000..5414c2c655e72 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/RateLimiter/CHANGELOG.md b/src/Symfony/Component/RateLimiter/CHANGELOG.md new file mode 100644 index 0000000000000..1e70f9a64318a --- /dev/null +++ b/src/Symfony/Component/RateLimiter/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.2.0 +----- + + * added the component diff --git a/src/Symfony/Component/RateLimiter/CompoundLimiter.php b/src/Symfony/Component/RateLimiter/CompoundLimiter.php new file mode 100644 index 0000000000000..ad246bace378b --- /dev/null +++ b/src/Symfony/Component/RateLimiter/CompoundLimiter.php @@ -0,0 +1,47 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +final class CompoundLimiter implements LimiterInterface +{ + private $limiters; + + /** + * @param LimiterInterface[] $limiters + */ + public function __construct(array $limiters) + { + $this->limiters = $limiters; + } + + public function consume(int $tokens = 1): bool + { + $allow = true; + foreach ($this->limiters as $limiter) { + $allow = $limiter->consume($tokens) && $allow; + } + + return $allow; + } + + public function reset(): void + { + foreach ($this->limiters as $limiter) { + $limiter->reset(); + } + } +} diff --git a/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php b/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php new file mode 100644 index 0000000000000..4e4e7fcaac9d7 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Exception/MaxWaitDurationExceededException.php @@ -0,0 +1,21 @@ + + * + * 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 MaxWaitDurationExceededException extends \RuntimeException +{ +} diff --git a/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php b/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php new file mode 100644 index 0000000000000..f6ef8dd18b91f --- /dev/null +++ b/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php @@ -0,0 +1,70 @@ + + * + * 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\LockInterface; +use Symfony\Component\Lock\NoLock; +use Symfony\Component\RateLimiter\Storage\StorageInterface; +use Symfony\Component\RateLimiter\Util\TimeUtil; + +/** + * @author Wouter de Jong + * + * @experimental in 5.2 + */ +final class FixedWindowLimiter implements LimiterInterface +{ + private $id; + private $limit; + private $interval; + private $storage; + private $lock; + + use ResetLimiterTrait; + + public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null) + { + $this->storage = $storage; + $this->lock = $lock ?? new NoLock(); + $this->id = $id; + $this->limit = $limit; + $this->interval = TimeUtil::dateIntervalToSeconds($interval); + } + + /** + * {@inheritdoc} + */ + public function consume(int $tokens = 1): bool + { + $this->lock->acquire(true); + + try { + $window = $this->storage->fetch($this->id); + if (null === $window) { + $window = new Window($this->id, $this->interval); + } + + $hitCount = $window->getHitCount(); + $availableTokens = $this->limit - $hitCount; + if ($availableTokens < $tokens) { + return false; + } + + $window->add($tokens); + $this->storage->save($window); + + return true; + } finally { + $this->lock->release(); + } + } +} diff --git a/src/Symfony/Component/RateLimiter/LICENSE b/src/Symfony/Component/RateLimiter/LICENSE new file mode 100644 index 0000000000000..a7ec70801827a --- /dev/null +++ b/src/Symfony/Component/RateLimiter/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2016-2020 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. diff --git a/src/Symfony/Component/RateLimiter/Limiter.php b/src/Symfony/Component/RateLimiter/Limiter.php new file mode 100644 index 0000000000000..3898e89018795 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Limiter.php @@ -0,0 +1,97 @@ + + * + * 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\Lock\NoLock; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\RateLimiter\Storage\StorageInterface; + +/** + * @author Wouter de Jong + * + * @experimental in 5.2 + */ +final class Limiter +{ + private $config; + private $storage; + private $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 ? $this->lockFactory->createLock($id) : new NoLock(); + + switch ($this->config['strategy']) { + case 'token_bucket': + return new TokenBucketLimiter($id, $this->config['limit'], $this->config['rate'], $this->storage, $lock); + + case 'fixed_window': + return new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock); + + default: + throw new \LogicException(sprintf('Limiter strategy "%s" does not exists, it must be either "token_bucket" or "fixed_window".', $this->config['strategy'])); + } + } + + protected static function configureOptions(OptionsResolver $options): void + { + $intervalNormalizer = static function (Options $options, string $interval): \DateInterval { + try { + return (new \DateTimeImmutable())->diff(new \DateTimeImmutable('+'.$interval)); + } catch (\Exception $e) { + if (!preg_match('/Failed to parse time string \(\+([^)]+)\)/', $e->getMessage(), $m)) { + throw $e; + } + + throw new \LogicException(sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $m[1])); + } + }; + + $options + ->define('id')->required() + ->define('strategy') + ->required() + ->allowedValues('token_bucket', 'fixed_window') + + ->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']); + }) + ; + } +} diff --git a/src/Symfony/Component/RateLimiter/LimiterInterface.php b/src/Symfony/Component/RateLimiter/LimiterInterface.php new file mode 100644 index 0000000000000..3d610f714eb56 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/LimiterInterface.php @@ -0,0 +1,33 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +interface LimiterInterface +{ + /** + * 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): bool; + + /** + * Resets the limit. + */ + public function reset(): void; +} diff --git a/src/Symfony/Component/RateLimiter/LimiterStateInterface.php b/src/Symfony/Component/RateLimiter/LimiterStateInterface.php new file mode 100644 index 0000000000000..e1df489107b8c --- /dev/null +++ b/src/Symfony/Component/RateLimiter/LimiterStateInterface.php @@ -0,0 +1,24 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +interface LimiterStateInterface extends \Serializable +{ + public function getId(): string; + + public function getExpirationTime(): ?int; +} diff --git a/src/Symfony/Component/RateLimiter/NoLimiter.php b/src/Symfony/Component/RateLimiter/NoLimiter.php new file mode 100644 index 0000000000000..720fda763dea8 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/NoLimiter.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter; + +/** + * 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 + * + * @experimental in 5.2 + */ +final class NoLimiter implements LimiterInterface +{ + public function consume(int $tokens = 1): bool + { + return true; + } + + public function reset(): void + { + } +} diff --git a/src/Symfony/Component/RateLimiter/README.md b/src/Symfony/Component/RateLimiter/README.md new file mode 100644 index 0000000000000..c26bbb8a46420 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/README.md @@ -0,0 +1,46 @@ +Rate Limiter Component +====================== + +The Rate Limiter component provides a Token Bucket implementation to +rate limit input and output in your application. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Getting Started +--------------- + +``` +$ composer require symfony/rate-limiter +``` + +```php +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; +use Symfony\Component\RateLimiter\Limiter; + +$limiter = new Limiter([ + 'id' => 'login', + 'strategy' => 'token_bucket', // or 'fixed_window' + 'limit' => 10, + 'rate' => ['interval' => '15 minutes'], +], new InMemoryStorage()); + +// 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)) { + // ... 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) diff --git a/src/Symfony/Component/RateLimiter/Rate.php b/src/Symfony/Component/RateLimiter/Rate.php new file mode 100644 index 0000000000000..9720c9ff4c199 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Rate.php @@ -0,0 +1,92 @@ + + * + * 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\Util\TimeUtil; + +/** + * Data object representing the fill rate of a token bucket. + * + * @author Wouter de Jong + * + * @experimental in 5.2 + */ +final class Rate +{ + private $refillTime; + private $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); + } + + /** + * @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. + * + * @return int the time in seconds + */ + public function calculateTimeForTokens(int $tokens): int + { + $cyclesRequired = ceil($tokens / $this->refillAmount); + + return TimeUtil::dateIntervalToSeconds($this->refillTime) * $cyclesRequired; + } + + /** + * 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; + } + + public function __toString(): string + { + return $this->refillTime->format('P%dDT%HH%iM%sS').'-'.$this->refillAmount; + } +} diff --git a/src/Symfony/Component/RateLimiter/Reservation.php b/src/Symfony/Component/RateLimiter/Reservation.php new file mode 100644 index 0000000000000..fc33c5ad0f6ea --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Reservation.php @@ -0,0 +1,45 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +final class Reservation +{ + private $timeToAct; + + /** + * @param float $timeToAct Unix timestamp in seconds when this reservation should act + */ + public function __construct(float $timeToAct) + { + $this->timeToAct = $timeToAct; + } + + public function getTimeToAct(): float + { + return $this->timeToAct; + } + + public function getWaitDuration(): float + { + return max(0, (-microtime(true)) + $this->timeToAct); + } + + public function wait(): void + { + usleep($this->getWaitDuration() * 1e6); + } +} diff --git a/src/Symfony/Component/RateLimiter/ResetLimiterTrait.php b/src/Symfony/Component/RateLimiter/ResetLimiterTrait.php new file mode 100644 index 0000000000000..2969bc0d5f304 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/ResetLimiterTrait.php @@ -0,0 +1,47 @@ + + * + * 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\LockInterface; +use Symfony\Component\RateLimiter\Storage\StorageInterface; + +/** + * @experimental in 5.2 + */ +trait ResetLimiterTrait +{ + /** + * @var LockInterface + */ + private $lock; + + /** + * @var StorageInterface + */ + private $storage; + + private $id; + + /** + * {@inheritdoc} + */ + public function reset(): void + { + try { + $this->lock->acquire(true); + + $this->storage->delete($this->id); + } finally { + $this->lock->release(); + } + } +} diff --git a/src/Symfony/Component/RateLimiter/Storage/CacheStorage.php b/src/Symfony/Component/RateLimiter/Storage/CacheStorage.php new file mode 100644 index 0000000000000..5c39b0fcd2b2d --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Storage/CacheStorage.php @@ -0,0 +1,56 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +class CacheStorage implements StorageInterface +{ + private $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)); + if (!$cacheItem->isHit()) { + return null; + } + + return $cacheItem->get(); + } + + public function delete(string $limiterStateId): void + { + $this->pool->deleteItem($limiterStateId); + } +} diff --git a/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php b/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php new file mode 100644 index 0000000000000..9f17392b2d2ae --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Storage/InMemoryStorage.php @@ -0,0 +1,62 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +class InMemoryStorage implements StorageInterface +{ + private $buckets = []; + + public function save(LimiterStateInterface $limiterState): void + { + if (isset($this->buckets[$limiterState->getId()])) { + [$expireAt, ] = $this->buckets[$limiterState->getId()]; + } + + if (null !== ($expireSeconds = $limiterState->getExpirationTime())) { + $expireAt = microtime(true) + $expireSeconds; + } + + $this->buckets[$limiterState->getId()] = [$expireAt, 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]); + } +} diff --git a/src/Symfony/Component/RateLimiter/Storage/StorageInterface.php b/src/Symfony/Component/RateLimiter/Storage/StorageInterface.php new file mode 100644 index 0000000000000..3c5ec6b8a07eb --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Storage/StorageInterface.php @@ -0,0 +1,28 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +interface StorageInterface +{ + public function save(LimiterStateInterface $limiterState): void; + + public function fetch(string $limiterStateId): ?LimiterStateInterface; + + public function delete(string $limiterStateId): void; +} diff --git a/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php new file mode 100644 index 0000000000000..ecf77e3718878 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Component\RateLimiter\CompoundLimiter; +use Symfony\Component\RateLimiter\FixedWindowLimiter; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; + +/** + * @group time-sensitive + */ +class CompoundLimiterTest extends TestCase +{ + private $storage; + + protected function setUp(): void + { + $this->storage = new InMemoryStorage(); + + ClockMock::register(InMemoryStorage::class); + } + + public function testConsume() + { + $limiter1 = $this->createLimiter(4, new \DateInterval('PT1S')); + $limiter2 = $this->createLimiter(8, new \DateInterval('PT10S')); + $limiter3 = $this->createLimiter(12, new \DateInterval('PT30S')); + $limiter = new CompoundLimiter([$limiter1, $limiter2, $limiter3]); + + $this->assertFalse($limiter->consume(5), 'Limiter 1 reached the limit'); + sleep(1); // reset limiter1's window + $limiter->consume(2); + + $this->assertTrue($limiter->consume()); + $this->assertFalse($limiter->consume(), 'Limiter 2 reached the limit'); + sleep(9); // reset limiter2's window + + $this->assertTrue($limiter->consume(3)); + $this->assertFalse($limiter->consume(), 'Limiter 3 reached the limit'); + sleep(20); // reset limiter3's window + + $this->assertTrue($limiter->consume()); + } + + 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 new file mode 100644 index 0000000000000..f2b5095197bf0 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Component\RateLimiter\FixedWindowLimiter; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; + +/** + * @group time-sensitive + */ +class FixedWindowLimiterTest extends TestCase +{ + private $storage; + + protected function setUp(): void + { + $this->storage = new InMemoryStorage(); + + ClockMock::register(InMemoryStorage::class); + } + + public function testConsume() + { + $limiter = $this->createLimiter(); + + // fill 9 tokens in 45 seconds + for ($i = 0; $i < 9; ++$i) { + $limiter->consume(); + sleep(5); + } + + $this->assertTrue($limiter->consume()); + $this->assertFalse($limiter->consume()); + } + + public function testConsumeOutsideInterval() + { + $limiter = $this->createLimiter(); + + // start window... + $limiter->consume(); + // ...add a max burst at the end of the window... + sleep(55); + $limiter->consume(9); + // ...try bursting again at the start of the next window + sleep(10); + $this->assertTrue($limiter->consume(10)); + } + + private function createLimiter(): FixedWindowLimiter + { + return new FixedWindowLimiter('test', 10, new \DateInterval('PT1M'), $this->storage); + } +} diff --git a/src/Symfony/Component/RateLimiter/Tests/LimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/LimiterTest.php new file mode 100644 index 0000000000000..8d1442f2807ac --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/LimiterTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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\Storage\StorageInterface; +use Symfony\Component\RateLimiter\TokenBucketLimiter; + +class LimiterTest extends TestCase +{ + public function testTokenBucket() + { + $factory = $this->createFactory([ + 'id' => 'test', + 'strategy' => 'token_bucket', + 'limit' => 10, + 'rate' => ['interval' => '1 second'], + ]); + $limiter = $factory->create('127.0.0.1'); + + $this->assertInstanceOf(TokenBucketLimiter::class, $limiter); + } + + public function testFixedWindow() + { + $factory = $this->createFactory([ + 'id' => 'test', + 'strategy' => 'fixed_window', + 'limit' => 10, + 'interval' => '1 minute', + ]); + $limiter = $factory->create(); + + $this->assertInstanceOf(FixedWindowLimiter::class, $limiter); + } + + public function testWrongInterval() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot parse interval "1 minut", please use a valid unit as described on https://www.php.net/datetime.formats.relative.'); + + $this->createFactory([ + 'id' => 'test', + 'strategy' => 'fixed_window', + 'limit' => 10, + 'interval' => '1 minut', + ]); + } + + private function createFactory(array $options) + { + return new Limiter($options, $this->createMock(StorageInterface::class), $this->createMock(LockFactory::class)); + } +} diff --git a/src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php b/src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php new file mode 100644 index 0000000000000..a7baae6c882ea --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/Storage/CacheStorageTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter\Tests\Storage; + +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\RateLimiter\Storage\CacheStorage; +use Symfony\Component\RateLimiter\Window; + +class CacheStorageTest extends TestCase +{ + private $pool; + private $storage; + + protected function setUp(): void + { + $this->pool = $this->createMock(CacheItemPoolInterface::class); + $this->storage = new CacheStorage($this->pool); + } + + public function testSave() + { + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->expects($this->once())->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); + $this->storage->save($window); + + // test that expiresAfter is only called when getExpirationAt() does not return null + $window = unserialize(serialize($window)); + $this->storage->save($window); + } + + public function testFetchExistingState() + { + $cacheItem = $this->createMock(CacheItemInterface::class); + $window = new Window('test', 10); + $cacheItem->expects($this->any())->method('get')->willReturn($window); + $cacheItem->expects($this->any())->method('isHit')->willReturn(true); + + $this->pool->expects($this->any())->method('getItem')->with(sha1('test'))->willReturn($cacheItem); + + $this->assertEquals($window, $this->storage->fetch('test')); + } + + public function testFetchNonExistingState() + { + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->expects($this->any())->method('isHit')->willReturn(false); + + $this->pool->expects($this->any())->method('getItem')->with(sha1('test'))->willReturn($cacheItem); + + $this->assertNull($this->storage->fetch('test')); + } +} diff --git a/src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php new file mode 100644 index 0000000000000..7c36f694bf775 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\RateLimiter\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; +use Symfony\Component\RateLimiter\Rate; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; +use Symfony\Component\RateLimiter\TokenBucket; +use Symfony\Component\RateLimiter\TokenBucketLimiter; + +/** + * @group time-sensitive + */ +class TokenBucketLimiterTest extends TestCase +{ + private $storage; + + protected function setUp(): void + { + $this->storage = new InMemoryStorage(); + + ClockMock::register(TokenBucketLimiter::class); + ClockMock::register(InMemoryStorage::class); + ClockMock::register(TokenBucket::class); + } + + public function testReserve() + { + $limiter = $this->createLimiter(); + + $this->assertEquals(0, $limiter->reserve(5)->getWaitDuration()); + $this->assertEquals(0, $limiter->reserve(5)->getWaitDuration()); + $this->assertEquals(1, $limiter->reserve(5)->getWaitDuration()); + } + + public function testReserveMoreTokensThanBucketSize() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot reserve more tokens (15) than the burst size of the rate limiter (10).'); + + $limiter = $this->createLimiter(); + $limiter->reserve(15); + } + + public function testReserveMaxWaitingTime() + { + $this->expectException(MaxWaitDurationExceededException::class); + + $limiter = $this->createLimiter(10, Rate::perMinute()); + + // enough free tokens + $this->assertEquals(0, $limiter->reserve(10, 300)->getWaitDuration()); + // waiting time within set maximum + $this->assertEquals(300, $limiter->reserve(5, 300)->getWaitDuration()); + // waiting time exceeded maximum time (as 5 tokens are already reserved) + $limiter->reserve(5, 300); + } + + public function testConsume() + { + $limiter = $this->createLimiter(); + + // enough free tokens + $this->assertTrue($limiter->consume(5)); + // there are only 5 available free tokens left now + $this->assertFalse($limiter->consume(10)); + $this->assertTrue($limiter->consume(5)); + } + + private function createLimiter($initialTokens = 10, Rate $rate = null) + { + return new TokenBucketLimiter('test', $initialTokens, $rate ?? Rate::perSecond(10), $this->storage); + } +} diff --git a/src/Symfony/Component/RateLimiter/TokenBucket.php b/src/Symfony/Component/RateLimiter/TokenBucket.php new file mode 100644 index 0000000000000..75bd9369a02a7 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/TokenBucket.php @@ -0,0 +1,84 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +final class TokenBucket implements LimiterStateInterface +{ + private $id; + private $tokens; + private $burstSize; + private $rate; + private $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) + { + $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 = $now - $this->timer; + + return min($this->burstSize, $this->tokens + $this->rate->calculateNewTokensDuringInterval($elapsed)); + } + + public function getExpirationTime(): int + { + return $this->rate->calculateTimeForTokens($this->burstSize); + } + + public function serialize(): string + { + return serialize([$this->id, $this->tokens, $this->timer, $this->burstSize, (string) $this->rate]); + } + + public function unserialize($serialized): void + { + [$this->id, $this->tokens, $this->timer, $this->burstSize, $rate] = unserialize($serialized); + + $this->rate = Rate::fromString($rate); + } +} diff --git a/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php b/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php new file mode 100644 index 0000000000000..df59e891cd606 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php @@ -0,0 +1,116 @@ + + * + * 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\LockInterface; +use Symfony\Component\Lock\NoLock; +use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException; +use Symfony\Component\RateLimiter\Storage\StorageInterface; + +/** + * @author Wouter de Jong + * + * @experimental in 5.2 + */ +final class TokenBucketLimiter implements LimiterInterface +{ + private $id; + private $maxBurst; + private $rate; + private $storage; + private $lock; + + use ResetLimiterTrait; + + 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 ?? new NoLock(); + } + + /** + * 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 \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 (null === $bucket) { + $bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate); + } + + $now = microtime(true); + $availableTokens = $bucket->getAvailableTokens($now); + if ($availableTokens >= $tokens) { + // tokens are now available, update bucket + $bucket->setTokens($availableTokens - $tokens); + $bucket->setTimer($now); + + $reservation = new Reservation($now); + } 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)); + } + + // at $now + $waitDuration all tokens will be reserved for this process, + // so no tokens are left for other processes. + $bucket->setTokens(0); + $bucket->setTimer($now + $waitDuration); + + $reservation = new Reservation($bucket->getTimer()); + } + + $this->storage->save($bucket); + } finally { + $this->lock->release(); + } + + return $reservation; + } + + /** + * {@inheritdoc} + */ + public function consume(int $tokens = 1): bool + { + try { + $this->reserve($tokens, 0); + + return true; + } catch (MaxWaitDurationExceededException $e) { + return false; + } + } +} diff --git a/src/Symfony/Component/RateLimiter/Util/TimeUtil.php b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php new file mode 100644 index 0000000000000..f8cd6e1e4925c --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php @@ -0,0 +1,29 @@ + + * + * 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 + * + * @internal + */ +final class TimeUtil +{ + public static function dateIntervalToSeconds(\DateInterval $interval): int + { + return (float) $interval->format('%s') // seconds + + $interval->format('%i') * 60 // minutes + + $interval->format('%H') * 3600 // hours + + $interval->format('%d') * 3600 * 24 // days + ; + } +} diff --git a/src/Symfony/Component/RateLimiter/Window.php b/src/Symfony/Component/RateLimiter/Window.php new file mode 100644 index 0000000000000..7fea99f9c6e7b --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Window.php @@ -0,0 +1,62 @@ + + * + * 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 + * + * @experimental in 5.2 + */ +final class Window implements LimiterStateInterface +{ + private $id; + private $hitCount = 0; + private $intervalInSeconds; + + public function __construct(string $id, int $intervalInSeconds) + { + $this->id = $id; + $this->intervalInSeconds = $intervalInSeconds; + } + + public function getId(): string + { + return $this->id; + } + + public function getExpirationTime(): ?int + { + return $this->intervalInSeconds; + } + + public function add(int $hits = 1) + { + $this->hitCount += $hits; + } + + public function getHitCount(): int + { + return $this->hitCount; + } + + public function serialize(): string + { + // $intervalInSeconds is not serialized, it should only be set + // upon first creation of the Window. + return serialize([$this->id, $this->hitCount]); + } + + public function unserialize($serialized): void + { + [$this->id, $this->hitCount] = unserialize($serialized); + } +} diff --git a/src/Symfony/Component/RateLimiter/composer.json b/src/Symfony/Component/RateLimiter/composer.json new file mode 100644 index 0000000000000..92e89c517a9fc --- /dev/null +++ b/src/Symfony/Component/RateLimiter/composer.json @@ -0,0 +1,38 @@ +{ + "name": "symfony/rate-limiter", + "type": "library", + "description": "Symfony Rate Limiter Component", + "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": ">=7.2.5", + "symfony/lock": "^5.2", + "symfony/options-resolver": "^5.1" + }, + "require-dev": { + "psr/cache": "^1.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\RateLimiter\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.2-dev" + } + } +} diff --git a/src/Symfony/Component/RateLimiter/phpunit.xml.dist b/src/Symfony/Component/RateLimiter/phpunit.xml.dist new file mode 100644 index 0000000000000..1afd852227fb2 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy