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 8cb4b2803e..ea75fdfebf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -30,6 +30,7 @@ use Symfony\Component\Mailer\Mailer; 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 @@ class Configuration implements ConfigurationInterface $this->addMailerSection($rootNode); $this->addSecretsSection($rootNode); $this->addNotifierSection($rootNode); + $this->addRateLimiterSection($rootNode); return $treeBuilder; } @@ -1707,4 +1709,50 @@ class Configuration implements ConfigurationInterface ->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 e375b3c555..acffba00fe 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\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\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 @@ class FrameworkExtension extends Extension $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 @@ class FrameworkExtension extends Extension } } + 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 0000000000..d249a56031 --- /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 3f5c803baa..cdc57ea30e 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 2c7920214c..00afdfd000 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -531,6 +531,10 @@ class ConfigurationTest extends TestCase '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 d61ba7f288..3eca7127eb 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 0000000000..074c6c3bda --- /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 0000000000..84c7add058 --- /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 0000000000..5414c2c655 --- /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 0000000000..1e70f9a643 --- /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 0000000000..ad246bace3 --- /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 0000000000..4e4e7fcaac --- /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 0000000000..f6ef8dd18b --- /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 0000000000..a7ec708018 --- /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 0000000000..3898e89018 --- /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 0000000000..3d610f714e --- /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 0000000000..e1df489107 --- /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 0000000000..720fda763d --- /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 0000000000..c26bbb8a46 --- /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 0000000000..9720c9ff4c --- /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 0000000000..fc33c5ad0f --- /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 0000000000..2969bc0d5f --- /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 0000000000..5c39b0fcd2 --- /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 0000000000..9f17392b2d --- /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 0000000000..3c5ec6b8a0 --- /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 0000000000..ecf77e3718 --- /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 0000000000..f2b5095197 --- /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 0000000000..8d1442f280 --- /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 0000000000..a7baae6c88 --- /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 0000000000..7c36f694bf --- /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 0000000000..75bd9369a0 --- /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 0000000000..df59e891cd --- /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 0000000000..f8cd6e1e49 --- /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 0000000000..7fea99f9c6 --- /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 0000000000..92e89c517a --- /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 0000000000..1afd852227 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +