[RFC] Introduce a RateLimiter component
This commit is contained in:
parent
9c8cd0827f
commit
67417a693e
@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\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'),
|
||||
])
|
||||
;
|
||||
};
|
@ -34,6 +34,7 @@
|
||||
<xsd:element name="http-client" type="http_client" minOccurs="0" maxOccurs="1" />
|
||||
<xsd:element name="mailer" type="mailer" minOccurs="0" maxOccurs="1" />
|
||||
<xsd:element name="http-cache" type="http_cache" minOccurs="0" maxOccurs="1" />
|
||||
<xsd:element name="rate-limiter" type="rate_limiter" minOccurs="0" maxOccurs="1" />
|
||||
</xsd:choice>
|
||||
|
||||
<xsd:attribute name="http-method-override" type="xsd:boolean" />
|
||||
@ -634,4 +635,30 @@
|
||||
<xsd:enumeration value="full" />
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:complexType name="rate_limiter">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="limiter" type="rate_limiter_limiter" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="enabled" type="xsd:boolean" />
|
||||
<xsd:attribute name="max-host-connections" type="xsd:integer" />
|
||||
<xsd:attribute name="mock-response-factory" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="rate_limiter_limiter">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="rate" type="rate_limiter_rate" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="lock" type="xsd:string" />
|
||||
<xsd:attribute name="storage" type="xsd:string" />
|
||||
<xsd:attribute name="strategy" type="xsd:string" />
|
||||
<xsd:attribute name="limit" type="xsd:int" />
|
||||
<xsd:attribute name="interval" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="rate_limiter_rate">
|
||||
<xsd:attribute name="interval" type="xsd:string" />
|
||||
<xsd:attribute name="amount" type="xsd:int" />
|
||||
</xsd:complexType>
|
||||
</xsd:schema>
|
||||
|
@ -531,6 +531,10 @@ class ConfigurationTest extends TestCase
|
||||
'debug' => '%kernel.debug%',
|
||||
'private_headers' => [],
|
||||
],
|
||||
'rate_limiter' => [
|
||||
'enabled' => false,
|
||||
'limiters' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
-----
|
||||
|
51
src/Symfony/Component/Lock/NoLock.php
Normal file
51
src/Symfony/Component/Lock/NoLock.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\Lock;
|
||||
|
||||
/**
|
||||
* A non locking lock.
|
||||
*
|
||||
* This can be used to disable locking in classes
|
||||
* requiring a lock.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
4
src/Symfony/Component/RateLimiter/.gitattributes
vendored
Normal file
4
src/Symfony/Component/RateLimiter/.gitattributes
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/Tests export-ignore
|
||||
/phpunit.xml.dist export-ignore
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
3
src/Symfony/Component/RateLimiter/.gitignore
vendored
Normal file
3
src/Symfony/Component/RateLimiter/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
composer.lock
|
||||
phpunit.xml
|
||||
vendor/
|
7
src/Symfony/Component/RateLimiter/CHANGELOG.md
Normal file
7
src/Symfony/Component/RateLimiter/CHANGELOG.md
Normal file
@ -0,0 +1,7 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
5.2.0
|
||||
-----
|
||||
|
||||
* added the component
|
47
src/Symfony/Component/RateLimiter/CompoundLimiter.php
Normal file
47
src/Symfony/Component/RateLimiter/CompoundLimiter.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\Exception;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @experimental in 5.2
|
||||
*/
|
||||
class MaxWaitDurationExceededException extends \RuntimeException
|
||||
{
|
||||
}
|
70
src/Symfony/Component/RateLimiter/FixedWindowLimiter.php
Normal file
70
src/Symfony/Component/RateLimiter/FixedWindowLimiter.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Lock\NoLock;
|
||||
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
||||
use Symfony\Component\RateLimiter\Util\TimeUtil;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
}
|
19
src/Symfony/Component/RateLimiter/LICENSE
Normal file
19
src/Symfony/Component/RateLimiter/LICENSE
Normal file
@ -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.
|
97
src/Symfony/Component/RateLimiter/Limiter.php
Normal file
97
src/Symfony/Component/RateLimiter/Limiter.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\NoLock;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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']);
|
||||
})
|
||||
;
|
||||
}
|
||||
}
|
33
src/Symfony/Component/RateLimiter/LimiterInterface.php
Normal file
33
src/Symfony/Component/RateLimiter/LimiterInterface.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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;
|
||||
}
|
24
src/Symfony/Component/RateLimiter/LimiterStateInterface.php
Normal file
24
src/Symfony/Component/RateLimiter/LimiterStateInterface.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @experimental in 5.2
|
||||
*/
|
||||
interface LimiterStateInterface extends \Serializable
|
||||
{
|
||||
public function getId(): string;
|
||||
|
||||
public function getExpirationTime(): ?int;
|
||||
}
|
34
src/Symfony/Component/RateLimiter/NoLimiter.php
Normal file
34
src/Symfony/Component/RateLimiter/NoLimiter.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
/**
|
||||
* Implements a non limiting limiter.
|
||||
*
|
||||
* This can be used in cases where an implementation requires a
|
||||
* limiter, but no rate limit should be enforced.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @experimental in 5.2
|
||||
*/
|
||||
final class NoLimiter implements LimiterInterface
|
||||
{
|
||||
public function consume(int $tokens = 1): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
}
|
||||
}
|
46
src/Symfony/Component/RateLimiter/README.md
Normal file
46
src/Symfony/Component/RateLimiter/README.md
Normal file
@ -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)
|
92
src/Symfony/Component/RateLimiter/Rate.php
Normal file
92
src/Symfony/Component/RateLimiter/Rate.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
use Symfony\Component\RateLimiter\Util\TimeUtil;
|
||||
|
||||
/**
|
||||
* Data object representing the fill rate of a token bucket.
|
||||
*
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
45
src/Symfony/Component/RateLimiter/Reservation.php
Normal file
45
src/Symfony/Component/RateLimiter/Reservation.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
47
src/Symfony/Component/RateLimiter/ResetLimiterTrait.php
Normal file
47
src/Symfony/Component/RateLimiter/ResetLimiterTrait.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
use Symfony\Component\Lock\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();
|
||||
}
|
||||
}
|
||||
}
|
56
src/Symfony/Component/RateLimiter/Storage/CacheStorage.php
Normal file
56
src/Symfony/Component/RateLimiter/Storage/CacheStorage.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\Storage;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\RateLimiter\LimiterStateInterface;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\Storage;
|
||||
|
||||
use Symfony\Component\RateLimiter\LimiterStateInterface;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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]);
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\Storage;
|
||||
|
||||
use Symfony\Component\RateLimiter\LimiterStateInterface;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @experimental in 5.2
|
||||
*/
|
||||
interface StorageInterface
|
||||
{
|
||||
public function save(LimiterStateInterface $limiterState): void;
|
||||
|
||||
public function fetch(string $limiterStateId): ?LimiterStateInterface;
|
||||
|
||||
public function delete(string $limiterStateId): void;
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\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);
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\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);
|
||||
}
|
||||
}
|
66
src/Symfony/Component/RateLimiter/Tests/LimiterTest.php
Normal file
66
src/Symfony/Component/RateLimiter/Tests/LimiterTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\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));
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\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'));
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\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);
|
||||
}
|
||||
}
|
84
src/Symfony/Component/RateLimiter/TokenBucket.php
Normal file
84
src/Symfony/Component/RateLimiter/TokenBucket.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
116
src/Symfony/Component/RateLimiter/TokenBucketLimiter.php
Normal file
116
src/Symfony/Component/RateLimiter/TokenBucketLimiter.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Lock\NoLock;
|
||||
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
|
||||
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
29
src/Symfony/Component/RateLimiter/Util/TimeUtil.php
Normal file
29
src/Symfony/Component/RateLimiter/Util/TimeUtil.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter\Util;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TimeUtil
|
||||
{
|
||||
public static function dateIntervalToSeconds(\DateInterval $interval): int
|
||||
{
|
||||
return (float) $interval->format('%s') // seconds
|
||||
+ $interval->format('%i') * 60 // minutes
|
||||
+ $interval->format('%H') * 3600 // hours
|
||||
+ $interval->format('%d') * 3600 * 24 // days
|
||||
;
|
||||
}
|
||||
}
|
62
src/Symfony/Component/RateLimiter/Window.php
Normal file
62
src/Symfony/Component/RateLimiter/Window.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\RateLimiter;
|
||||
|
||||
/**
|
||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
38
src/Symfony/Component/RateLimiter/composer.json
Normal file
38
src/Symfony/Component/RateLimiter/composer.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
30
src/Symfony/Component/RateLimiter/phpunit.xml.dist
Normal file
30
src/Symfony/Component/RateLimiter/phpunit.xml.dist
Normal file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
bootstrap="vendor/autoload.php"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true"
|
||||
>
|
||||
<php>
|
||||
<ini name="error_reporting" value="-1" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Symfony Rate Limiter Component Test Suite">
|
||||
<directory>./Tests/</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<filter>
|
||||
<whitelist>
|
||||
<directory>./</directory>
|
||||
<exclude>
|
||||
<directory>./Tests</directory>
|
||||
<directory>./vendor</directory>
|
||||
</exclude>
|
||||
</whitelist>
|
||||
</filter>
|
||||
</phpunit>
|
Reference in New Issue
Block a user