[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\Messenger\MessageBusInterface;
|
||||||
use Symfony\Component\Notifier\Notifier;
|
use Symfony\Component\Notifier\Notifier;
|
||||||
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
|
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
|
||||||
|
use Symfony\Component\RateLimiter\TokenBucketLimiter;
|
||||||
use Symfony\Component\Serializer\Serializer;
|
use Symfony\Component\Serializer\Serializer;
|
||||||
use Symfony\Component\Translation\Translator;
|
use Symfony\Component\Translation\Translator;
|
||||||
use Symfony\Component\Validator\Validation;
|
use Symfony\Component\Validator\Validation;
|
||||||
@ -134,6 +135,7 @@ class Configuration implements ConfigurationInterface
|
|||||||
$this->addMailerSection($rootNode);
|
$this->addMailerSection($rootNode);
|
||||||
$this->addSecretsSection($rootNode);
|
$this->addSecretsSection($rootNode);
|
||||||
$this->addNotifierSection($rootNode);
|
$this->addNotifierSection($rootNode);
|
||||||
|
$this->addRateLimiterSection($rootNode);
|
||||||
|
|
||||||
return $treeBuilder;
|
return $treeBuilder;
|
||||||
}
|
}
|
||||||
@ -1707,4 +1709,50 @@ class Configuration implements ConfigurationInterface
|
|||||||
->end()
|
->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\PropertyReadInfoExtractorInterface;
|
||||||
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
|
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
|
||||||
use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface;
|
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\AnnotationDirectoryLoader;
|
||||||
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
|
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
|
||||||
use Symfony\Component\Security\Core\Security;
|
use Symfony\Component\Security\Core\Security;
|
||||||
@ -173,6 +176,7 @@ class FrameworkExtension extends Extension
|
|||||||
private $mailerConfigEnabled = false;
|
private $mailerConfigEnabled = false;
|
||||||
private $httpClientConfigEnabled = false;
|
private $httpClientConfigEnabled = false;
|
||||||
private $notifierConfigEnabled = false;
|
private $notifierConfigEnabled = false;
|
||||||
|
private $lockConfigEnabled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Responds to the app.config configuration parameter.
|
* Responds to the app.config configuration parameter.
|
||||||
@ -405,10 +409,18 @@ class FrameworkExtension extends Extension
|
|||||||
$this->registerPropertyInfoConfiguration($container, $loader);
|
$this->registerPropertyInfoConfiguration($container, $loader);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isConfigEnabled($container, $config['lock'])) {
|
if ($this->lockConfigEnabled = $this->isConfigEnabled($container, $config['lock'])) {
|
||||||
$this->registerLockConfiguration($config['lock'], $container, $loader);
|
$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 ($this->isConfigEnabled($container, $config['web_link'])) {
|
||||||
if (!class_exists(HttpHeaderSerializer::class)) {
|
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".');
|
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
|
private function resolveTrustedHeaders(array $headers): int
|
||||||
{
|
{
|
||||||
$trustedHeaders = 0;
|
$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="http-client" type="http_client" minOccurs="0" maxOccurs="1" />
|
||||||
<xsd:element name="mailer" type="mailer" 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="http-cache" type="http_cache" minOccurs="0" maxOccurs="1" />
|
||||||
|
<xsd:element name="rate-limiter" type="rate_limiter" minOccurs="0" maxOccurs="1" />
|
||||||
</xsd:choice>
|
</xsd:choice>
|
||||||
|
|
||||||
<xsd:attribute name="http-method-override" type="xsd:boolean" />
|
<xsd:attribute name="http-method-override" type="xsd:boolean" />
|
||||||
@ -634,4 +635,30 @@
|
|||||||
<xsd:enumeration value="full" />
|
<xsd:enumeration value="full" />
|
||||||
</xsd:restriction>
|
</xsd:restriction>
|
||||||
</xsd:simpleType>
|
</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>
|
</xsd:schema>
|
||||||
|
@ -531,6 +531,10 @@ class ConfigurationTest extends TestCase
|
|||||||
'debug' => '%kernel.debug%',
|
'debug' => '%kernel.debug%',
|
||||||
'private_headers' => [],
|
'private_headers' => [],
|
||||||
],
|
],
|
||||||
|
'rate_limiter' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'limiters' => [],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ CHANGELOG
|
|||||||
|
|
||||||
* `MongoDbStore` does not implement `BlockingStoreInterface` anymore, typehint against `PersistingStoreInterface` instead.
|
* `MongoDbStore` does not implement `BlockingStoreInterface` anymore, typehint against `PersistingStoreInterface` instead.
|
||||||
* added support for shared locks
|
* added support for shared locks
|
||||||
|
* added `NoLock`
|
||||||
|
|
||||||
5.1.0
|
5.1.0
|
||||||
-----
|
-----
|
||||||
@ -25,10 +26,10 @@ CHANGELOG
|
|||||||
* added InvalidTtlException
|
* added InvalidTtlException
|
||||||
* deprecated `StoreInterface` in favor of `BlockingStoreInterface` and `PersistingStoreInterface`
|
* deprecated `StoreInterface` in favor of `BlockingStoreInterface` and `PersistingStoreInterface`
|
||||||
* `Factory` is deprecated, use `LockFactory` instead
|
* `Factory` is deprecated, use `LockFactory` instead
|
||||||
* `StoreFactory::createStore` allows PDO and Zookeeper DSN.
|
* `StoreFactory::createStore` allows PDO and Zookeeper DSN.
|
||||||
* deprecated services `lock.store.flock`, `lock.store.semaphore`, `lock.store.memcached.abstract` and `lock.store.redis.abstract`,
|
* deprecated services `lock.store.flock`, `lock.store.semaphore`, `lock.store.memcached.abstract` and `lock.store.redis.abstract`,
|
||||||
use `StoreFactory::createStore` instead.
|
use `StoreFactory::createStore` instead.
|
||||||
|
|
||||||
4.2.0
|
4.2.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