feature #37546 [RFC] Introduce a RateLimiter component (wouterj)

This PR was squashed before being merged into the 5.2-dev branch.

Discussion
----------

[RFC] Introduce a RateLimiter component

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Refs #37444
| License       | MIT
| Doc PR        | tbd

Based on the discussions in #37444, I decided to write a general purpose RateLimiter component. This implementation uses the token bucket algorithm, inspired by the [Go's time/rate](3af7569d3a/rate/rate.go) library and the [PHP `bandwidth-throttle/token-bucket` package](https://github.com/bandwidth-throttle/token-bucket) (which is [unmaintained for years](https://github.com/bandwidth-throttle/token-bucket/issues/19)).

### Usage

The component has two main methods:

* `Limiter::reserve(int $tokens, int $maxTime)`, allocates `$tokens` and returns a `Reservation` containing the wait time. Use this method if your process wants to wait before consuming the token.
* `Limiter::consume(int $tokens)`, checks if `$tokens` are available now and discards the reservation if that's not the case. Use this method if you want to skip when there are not enough tokens at this moment.

The component uses the Lock component to make sure it can be used in parallel processes.

Example:

```php
<?php

namespace App\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\LimiterFactory;

class LimitListener
{
    private $limiterFactory;

    public function __construct(LimiterFactory $apiLimiterFactory)
    {
        $this->limiterFactory = $apiLimiterFactory;
    }

    public function __invoke(RequestEvent $event)
    {
        $ip = $event->getRequest()->getClientIp();

        $limiter = $this->limiterFactory->createLimiter(preg_replace('/[^a-zA-Z0-9]/', '-', $ip));
        if (!$limiter->consume()) {
            $event->setResponse(new Response('Too many requests', 429));
        }
    }
}
```

### Usefullness of the component

I think a generic rate limiter is usefull in quite some places:

* Add a login throttling feature in Symfony
* <s>Rate limiting outgoing API calls (e.g. HttpClient), to prevent hitting upstream API limits.</s> See #37471 (and https://blog.heroku.com/rate-throttle-api-client )
* Allowing users to easily implement API rate limits in their own Symfony-based APIs.

### State of the art

There are some rate limiting packages in PHP, but I think there is no precendent for this component:

* [`graham-campbell/throttle`](https://github.com/GrahamCampbell/Laravel-Throttle) is heavily relying on Laravel. It is however very popular, proofing there is a need for such feature
* [`nikolaposa/rate-limit`](https://github.com/nikolaposa/rate-limit) does not implement reservation of tokens and as such less feature complete. Also its architecture combines the rate limiter and storage, making it harder to implement different storages.

### Todo

If it is agreed that this component can bring something to Symfony, it needs some more love:

* [x] Add more tests
* [x] Integrate with the FrameworkBundle
* [x] Add sliding window implementation
* [x] Add integration with the Security component
* <s>Maybe add more storage implementations? I didn't want to duplicate storage functionalities already existing in the Lock and Cache component, thus I for now focused mostly on integrating the Cache adapters. But maybe a special Doctrine adapter makes sense?</s>

Commits
-------

67417a693e [RFC] Introduce a RateLimiter component
This commit is contained in:
Fabien Potencier 2020-09-16 15:45:08 +02:00
commit 7cd5bbf458
36 changed files with 1652 additions and 4 deletions

View File

@ -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()
;
}
}

View File

@ -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;

View File

@ -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'),
])
;
};

View File

@ -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>

View File

@ -531,6 +531,10 @@ class ConfigurationTest extends TestCase
'debug' => '%kernel.debug%',
'private_headers' => [],
],
'rate_limiter' => [
'enabled' => false,
'limiters' => [],
],
];
}
}

View File

@ -6,6 +6,7 @@ CHANGELOG
* `MongoDbStore` does not implement `BlockingStoreInterface` anymore, typehint against `PersistingStoreInterface` instead.
* added support for shared locks
* added `NoLock`
5.1.0
-----
@ -25,10 +26,10 @@ CHANGELOG
* added InvalidTtlException
* deprecated `StoreInterface` in favor of `BlockingStoreInterface` and `PersistingStoreInterface`
* `Factory` is deprecated, use `LockFactory` instead
* `StoreFactory::createStore` allows PDO and Zookeeper DSN.
* deprecated services `lock.store.flock`, `lock.store.semaphore`, `lock.store.memcached.abstract` and `lock.store.redis.abstract`,
* `StoreFactory::createStore` allows PDO and Zookeeper DSN.
* deprecated services `lock.store.flock`, `lock.store.semaphore`, `lock.store.memcached.abstract` and `lock.store.redis.abstract`,
use `StoreFactory::createStore` instead.
4.2.0
-----

View 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;
}
}

View File

@ -0,0 +1,4 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore

View File

@ -0,0 +1,3 @@
composer.lock
phpunit.xml
vendor/

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
5.2.0
-----
* added the component

View 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();
}
}
}

View File

@ -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
{
}

View 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();
}
}
}

View 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.

View 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']);
})
;
}
}

View 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;
}

View 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;
}

View 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
{
}
}

View 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)

View 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;
}
}

View 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);
}
}

View 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();
}
}
}

View 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);
}
}

View 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\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]);
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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));
}
}

View File

@ -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'));
}
}

View File

@ -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);
}
}

View 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);
}
}

View 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;
}
}
}

View 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
;
}
}

View 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);
}
}

View 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"
}
}
}

View 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>