[RateLimiter] Added reserve() to LimiterInterface and rename Limiter to RateLimiter
This commit is contained in:
parent
b3a1851d43
commit
cd34f21254
@ -128,8 +128,8 @@ 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\LimiterInterface;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiter;
|
||||||
use Symfony\Component\RateLimiter\Storage\CacheStorage;
|
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;
|
||||||
@ -2266,7 +2266,7 @@ class FrameworkExtension extends Extension
|
|||||||
$limiterConfig['id'] = $name;
|
$limiterConfig['id'] = $name;
|
||||||
$limiter->replaceArgument(0, $limiterConfig);
|
$limiter->replaceArgument(0, $limiterConfig);
|
||||||
|
|
||||||
$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
|
$container->registerAliasForArgument($limiterId, RateLimiter::class, $name.'.limiter');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTrustedHeaders(array $headers): int
|
private function resolveTrustedHeaders(array $headers): int
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||||
|
|
||||||
use Symfony\Component\RateLimiter\Limiter;
|
use Symfony\Component\RateLimiter\RateLimiter;
|
||||||
|
|
||||||
return static function (ContainerConfigurator $container) {
|
return static function (ContainerConfigurator $container) {
|
||||||
$container->services()
|
$container->services()
|
||||||
@ -19,7 +19,7 @@ return static function (ContainerConfigurator $container) {
|
|||||||
->parent('cache.app')
|
->parent('cache.app')
|
||||||
->tag('cache.pool')
|
->tag('cache.pool')
|
||||||
|
|
||||||
->set('limiter', Limiter::class)
|
->set('limiter', RateLimiter::class)
|
||||||
->abstract()
|
->abstract()
|
||||||
->args([
|
->args([
|
||||||
abstract_arg('config'),
|
abstract_arg('config'),
|
||||||
|
@ -18,7 +18,7 @@ use Symfony\Component\DependencyInjection\ChildDefinition;
|
|||||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||||
use Symfony\Component\DependencyInjection\Reference;
|
use Symfony\Component\DependencyInjection\Reference;
|
||||||
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
|
use Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface;
|
||||||
use Symfony\Component\RateLimiter\Limiter;
|
use Symfony\Component\RateLimiter\RateLimiter;
|
||||||
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
|
use Symfony\Component\Security\Http\EventListener\LoginThrottlingListener;
|
||||||
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
|
use Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter;
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ class LoginThrottlingFactory implements AuthenticatorFactoryInterface, SecurityF
|
|||||||
throw new \LogicException('Login throttling requires symfony/security-http:^5.2.');
|
throw new \LogicException('Login throttling requires symfony/security-http:^5.2.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!class_exists(Limiter::class)) {
|
if (!class_exists(RateLimiter::class)) {
|
||||||
throw new \LogicException('Login throttling requires symfony/rate-limiter to be installed and enabled.');
|
throw new \LogicException('Login throttling requires symfony/rate-limiter to be installed and enabled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
namespace Symfony\Component\RateLimiter;
|
namespace Symfony\Component\RateLimiter;
|
||||||
|
|
||||||
|
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||||
*
|
*
|
||||||
@ -31,6 +33,11 @@ final class CompoundLimiter implements LimiterInterface
|
|||||||
$this->limiters = $limiters;
|
$this->limiters = $limiters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
|
||||||
|
{
|
||||||
|
throw new ReserveNotSupportedException(__CLASS__);
|
||||||
|
}
|
||||||
|
|
||||||
public function consume(int $tokens = 1): Limit
|
public function consume(int $tokens = 1): Limit
|
||||||
{
|
{
|
||||||
$minimalLimit = null;
|
$minimalLimit = null;
|
||||||
|
@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
namespace Symfony\Component\RateLimiter\Exception;
|
namespace Symfony\Component\RateLimiter\Exception;
|
||||||
|
|
||||||
|
use Symfony\Component\RateLimiter\Limit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||||
*
|
*
|
||||||
@ -18,4 +20,17 @@ namespace Symfony\Component\RateLimiter\Exception;
|
|||||||
*/
|
*/
|
||||||
class MaxWaitDurationExceededException extends \RuntimeException
|
class MaxWaitDurationExceededException extends \RuntimeException
|
||||||
{
|
{
|
||||||
|
private $limit;
|
||||||
|
|
||||||
|
public function __construct(string $message, Limit $limit, int $code = 0, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
|
||||||
|
$this->limit = $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLimit(): Limit
|
||||||
|
{
|
||||||
|
return $this->limit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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\RateLimiter\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||||
|
*
|
||||||
|
* @experimental in 5.2
|
||||||
|
*/
|
||||||
|
class ReserveNotSupportedException extends \BadMethodCallException
|
||||||
|
{
|
||||||
|
public function __construct(string $limiterClass, int $code = 0, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct(sprintf('Reserving tokens is not supported by "%s".', $limiterClass), $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
@ -13,6 +13,7 @@ namespace Symfony\Component\RateLimiter;
|
|||||||
|
|
||||||
use Symfony\Component\Lock\LockInterface;
|
use Symfony\Component\Lock\LockInterface;
|
||||||
use Symfony\Component\Lock\NoLock;
|
use Symfony\Component\Lock\NoLock;
|
||||||
|
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
|
||||||
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
||||||
use Symfony\Component\RateLimiter\Util\TimeUtil;
|
use Symfony\Component\RateLimiter\Util\TimeUtil;
|
||||||
|
|
||||||
@ -33,6 +34,10 @@ final class FixedWindowLimiter implements LimiterInterface
|
|||||||
|
|
||||||
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null)
|
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null)
|
||||||
{
|
{
|
||||||
|
if ($limit < 1) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', __CLASS__));
|
||||||
|
}
|
||||||
|
|
||||||
$this->storage = $storage;
|
$this->storage = $storage;
|
||||||
$this->lock = $lock ?? new NoLock();
|
$this->lock = $lock ?? new NoLock();
|
||||||
$this->id = $id;
|
$this->id = $id;
|
||||||
@ -40,42 +45,61 @@ final class FixedWindowLimiter implements LimiterInterface
|
|||||||
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
|
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
|
||||||
* {@inheritdoc}
|
|
||||||
*/
|
|
||||||
public function consume(int $tokens = 1): Limit
|
|
||||||
{
|
{
|
||||||
|
if ($tokens > $this->limit) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit));
|
||||||
|
}
|
||||||
|
|
||||||
$this->lock->acquire(true);
|
$this->lock->acquire(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$window = $this->storage->fetch($this->id);
|
$window = $this->storage->fetch($this->id);
|
||||||
if (!$window instanceof Window) {
|
if (!$window instanceof Window) {
|
||||||
$window = new Window($this->id, $this->interval);
|
$window = new Window($this->id, $this->interval, $this->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
$hitCount = $window->getHitCount();
|
$now = microtime(true);
|
||||||
$availableTokens = $this->getAvailableTokens($hitCount);
|
$availableTokens = $window->getAvailableTokens($now);
|
||||||
$windowStart = \DateTimeImmutable::createFromFormat('U', time());
|
if ($availableTokens >= $tokens) {
|
||||||
if ($availableTokens < $tokens) {
|
$window->add($tokens);
|
||||||
return new Limit($availableTokens, $this->getRetryAfter($windowStart), false);
|
|
||||||
|
$reservation = new Reservation($now, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true));
|
||||||
|
} else {
|
||||||
|
$remainingTokens = $tokens - $availableTokens;
|
||||||
|
$waitDuration = $window->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), new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
|
||||||
}
|
}
|
||||||
|
|
||||||
$window->add($tokens);
|
$window->add($tokens);
|
||||||
$this->storage->save($window);
|
|
||||||
|
|
||||||
return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true);
|
$reservation = new Reservation($now + $waitDuration, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
|
||||||
|
}
|
||||||
|
$this->storage->save($window);
|
||||||
} finally {
|
} finally {
|
||||||
$this->lock->release();
|
$this->lock->release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function consume(int $tokens = 1): Limit
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->reserve($tokens, 0)->getLimit();
|
||||||
|
} catch (MaxWaitDurationExceededException $e) {
|
||||||
|
return $e->getLimit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAvailableTokens(int $hitCount): int
|
public function getAvailableTokens(int $hitCount): int
|
||||||
{
|
{
|
||||||
return $this->limit - $hitCount;
|
return $this->limit - $hitCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getRetryAfter(\DateTimeImmutable $windowStart): \DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $windowStart->add(new \DateInterval(sprintf('PT%sS', $this->interval)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,9 @@
|
|||||||
|
|
||||||
namespace Symfony\Component\RateLimiter;
|
namespace Symfony\Component\RateLimiter;
|
||||||
|
|
||||||
|
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
|
||||||
|
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||||
*
|
*
|
||||||
@ -18,6 +21,22 @@ namespace Symfony\Component\RateLimiter;
|
|||||||
*/
|
*/
|
||||||
interface LimiterInterface
|
interface LimiterInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 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 ReserveNotSupportedException if this limiter implementation doesn't support reserving tokens
|
||||||
|
* @throws \InvalidArgumentException if $tokens is larger than the maximum burst size
|
||||||
|
*/
|
||||||
|
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use this method if you intend to drop if the required number
|
* Use this method if you intend to drop if the required number
|
||||||
* of tokens is unavailable.
|
* of tokens is unavailable.
|
||||||
|
@ -23,6 +23,11 @@ namespace Symfony\Component\RateLimiter;
|
|||||||
*/
|
*/
|
||||||
final class NoLimiter implements LimiterInterface
|
final class NoLimiter implements LimiterInterface
|
||||||
{
|
{
|
||||||
|
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
|
||||||
|
{
|
||||||
|
return new Reservation(time(), new Limit(\INF, new \DateTimeImmutable(), true));
|
||||||
|
}
|
||||||
|
|
||||||
public function consume(int $tokens = 1): Limit
|
public function consume(int $tokens = 1): Limit
|
||||||
{
|
{
|
||||||
return new Limit(\INF, new \DateTimeImmutable(), true);
|
return new Limit(\INF, new \DateTimeImmutable(), true);
|
||||||
|
@ -18,9 +18,9 @@ $ composer require symfony/rate-limiter
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
||||||
use Symfony\Component\RateLimiter\Limiter;
|
use Symfony\Component\RateLimiter\RateLimiter;
|
||||||
|
|
||||||
$limiter = new Limiter([
|
$limiter = new RateLimiter([
|
||||||
'id' => 'login',
|
'id' => 'login',
|
||||||
'strategy' => 'token_bucket', // or 'fixed_window'
|
'strategy' => 'token_bucket', // or 'fixed_window'
|
||||||
'limit' => 10,
|
'limit' => 10,
|
||||||
|
@ -22,7 +22,7 @@ use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
|||||||
*
|
*
|
||||||
* @experimental in 5.2
|
* @experimental in 5.2
|
||||||
*/
|
*/
|
||||||
final class Limiter
|
final class RateLimiter
|
||||||
{
|
{
|
||||||
private $config;
|
private $config;
|
||||||
private $storage;
|
private $storage;
|
@ -19,13 +19,15 @@ namespace Symfony\Component\RateLimiter;
|
|||||||
final class Reservation
|
final class Reservation
|
||||||
{
|
{
|
||||||
private $timeToAct;
|
private $timeToAct;
|
||||||
|
private $limit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param float $timeToAct Unix timestamp in seconds when this reservation should act
|
* @param float $timeToAct Unix timestamp in seconds when this reservation should act
|
||||||
*/
|
*/
|
||||||
public function __construct(float $timeToAct)
|
public function __construct(float $timeToAct, Limit $limit)
|
||||||
{
|
{
|
||||||
$this->timeToAct = $timeToAct;
|
$this->timeToAct = $timeToAct;
|
||||||
|
$this->limit = $limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTimeToAct(): float
|
public function getTimeToAct(): float
|
||||||
@ -38,6 +40,11 @@ final class Reservation
|
|||||||
return max(0, (-microtime(true)) + $this->timeToAct);
|
return max(0, (-microtime(true)) + $this->timeToAct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLimit(): Limit
|
||||||
|
{
|
||||||
|
return $this->limit;
|
||||||
|
}
|
||||||
|
|
||||||
public function wait(): void
|
public function wait(): void
|
||||||
{
|
{
|
||||||
usleep($this->getWaitDuration() * 1e6);
|
usleep($this->getWaitDuration() * 1e6);
|
||||||
|
@ -13,6 +13,7 @@ namespace Symfony\Component\RateLimiter;
|
|||||||
|
|
||||||
use Symfony\Component\Lock\LockInterface;
|
use Symfony\Component\Lock\LockInterface;
|
||||||
use Symfony\Component\Lock\NoLock;
|
use Symfony\Component\Lock\NoLock;
|
||||||
|
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
|
||||||
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
||||||
use Symfony\Component\RateLimiter\Util\TimeUtil;
|
use Symfony\Component\RateLimiter\Util\TimeUtil;
|
||||||
|
|
||||||
@ -67,6 +68,11 @@ final class SlidingWindowLimiter implements LimiterInterface
|
|||||||
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
|
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
|
||||||
|
{
|
||||||
|
throw new ReserveNotSupportedException(__CLASS__);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
@ -14,6 +14,7 @@ namespace Symfony\Component\RateLimiter\Tests;
|
|||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bridge\PhpUnit\ClockMock;
|
use Symfony\Bridge\PhpUnit\ClockMock;
|
||||||
use Symfony\Component\RateLimiter\CompoundLimiter;
|
use Symfony\Component\RateLimiter\CompoundLimiter;
|
||||||
|
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
|
||||||
use Symfony\Component\RateLimiter\FixedWindowLimiter;
|
use Symfony\Component\RateLimiter\FixedWindowLimiter;
|
||||||
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
||||||
|
|
||||||
@ -38,22 +39,26 @@ class CompoundLimiterTest extends TestCase
|
|||||||
$limiter3 = $this->createLimiter(12, new \DateInterval('PT30S'));
|
$limiter3 = $this->createLimiter(12, new \DateInterval('PT30S'));
|
||||||
$limiter = new CompoundLimiter([$limiter1, $limiter2, $limiter3]);
|
$limiter = new CompoundLimiter([$limiter1, $limiter2, $limiter3]);
|
||||||
|
|
||||||
// Reach limiter 1 limit, verify that limiter2 available tokens reduced by 5 and fetch successfully limiter 1
|
$this->assertEquals(0, $limiter->consume(4)->getRemainingTokens(), 'Limiter 1 reached the limit');
|
||||||
$this->assertEquals(3, $limiter->consume(5)->getRemainingTokens(), 'Limiter 1 reached the limit');
|
|
||||||
sleep(1); // reset limiter1's window
|
sleep(1); // reset limiter1's window
|
||||||
$this->assertTrue($limiter->consume(2)->isAccepted());
|
|
||||||
|
|
||||||
// Reach limiter 2 limit, verify that limiter2 available tokens reduced by 5 and and fetch successfully
|
|
||||||
$this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 2 has no remaining tokens left');
|
|
||||||
sleep(9); // reset limiter2's window
|
|
||||||
$this->assertTrue($limiter->consume(3)->isAccepted());
|
$this->assertTrue($limiter->consume(3)->isAccepted());
|
||||||
|
|
||||||
// Reach limiter 3 limit, verify that limiter2 available tokens reduced by 5 and fetch successfully
|
$this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 2 has no remaining tokens left');
|
||||||
|
sleep(10); // reset limiter2's window
|
||||||
|
$this->assertTrue($limiter->consume(3)->isAccepted());
|
||||||
|
|
||||||
$this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 3 reached the limit');
|
$this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 3 reached the limit');
|
||||||
sleep(20); // reset limiter3's window
|
sleep(20); // reset limiter3's window
|
||||||
$this->assertTrue($limiter->consume()->isAccepted());
|
$this->assertTrue($limiter->consume()->isAccepted());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testReserve()
|
||||||
|
{
|
||||||
|
$this->expectException(ReserveNotSupportedException::class);
|
||||||
|
|
||||||
|
(new CompoundLimiter([$this->createLimiter(4, new \DateInterval('PT1S'))]))->reserve();
|
||||||
|
}
|
||||||
|
|
||||||
private function createLimiter(int $limit, \DateInterval $interval): FixedWindowLimiter
|
private function createLimiter(int $limit, \DateInterval $interval): FixedWindowLimiter
|
||||||
{
|
{
|
||||||
return new FixedWindowLimiter('test'.$limit, $limit, $interval, $this->storage);
|
return new FixedWindowLimiter('test'.$limit, $limit, $interval, $this->storage);
|
||||||
|
@ -60,7 +60,7 @@ class FixedWindowLimiterTest extends TestCase
|
|||||||
sleep(10);
|
sleep(10);
|
||||||
$limit = $limiter->consume(10);
|
$limit = $limiter->consume(10);
|
||||||
$this->assertEquals(0, $limit->getRemainingTokens());
|
$this->assertEquals(0, $limit->getRemainingTokens());
|
||||||
$this->assertEquals(time() + 60, $limit->getRetryAfter()->getTimestamp());
|
$this->assertTrue($limit->isAccepted());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testWrongWindowFromCache()
|
public function testWrongWindowFromCache()
|
||||||
|
@ -14,7 +14,7 @@ namespace Symfony\Component\RateLimiter\Tests;
|
|||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\RateLimiter\FixedWindowLimiter;
|
use Symfony\Component\RateLimiter\FixedWindowLimiter;
|
||||||
use Symfony\Component\RateLimiter\Limiter;
|
use Symfony\Component\RateLimiter\RateLimiter;
|
||||||
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
use Symfony\Component\RateLimiter\Storage\StorageInterface;
|
||||||
use Symfony\Component\RateLimiter\TokenBucketLimiter;
|
use Symfony\Component\RateLimiter\TokenBucketLimiter;
|
||||||
|
|
||||||
@ -61,6 +61,6 @@ class LimiterTest extends TestCase
|
|||||||
|
|
||||||
private function createFactory(array $options)
|
private function createFactory(array $options)
|
||||||
{
|
{
|
||||||
return new Limiter($options, $this->createMock(StorageInterface::class), $this->createMock(LockFactory::class));
|
return new RateLimiter($options, $this->createMock(StorageInterface::class), $this->createMock(LockFactory::class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ namespace Symfony\Component\RateLimiter\Tests;
|
|||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Bridge\PhpUnit\ClockMock;
|
use Symfony\Bridge\PhpUnit\ClockMock;
|
||||||
|
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
|
||||||
use Symfony\Component\RateLimiter\SlidingWindowLimiter;
|
use Symfony\Component\RateLimiter\SlidingWindowLimiter;
|
||||||
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
||||||
|
|
||||||
@ -50,6 +51,13 @@ class SlidingWindowLimiterTest extends TestCase
|
|||||||
$this->assertTrue($limit->isAccepted());
|
$this->assertTrue($limit->isAccepted());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testReserve()
|
||||||
|
{
|
||||||
|
$this->expectException(ReserveNotSupportedException::class);
|
||||||
|
|
||||||
|
$this->createLimiter()->reserve();
|
||||||
|
}
|
||||||
|
|
||||||
private function createLimiter(): SlidingWindowLimiter
|
private function createLimiter(): SlidingWindowLimiter
|
||||||
{
|
{
|
||||||
return new SlidingWindowLimiter('test', 10, new \DateInterval('PT12S'), $this->storage);
|
return new SlidingWindowLimiter('test', 10, new \DateInterval('PT12S'), $this->storage);
|
||||||
|
@ -31,15 +31,14 @@ class CacheStorageTest extends TestCase
|
|||||||
public function testSave()
|
public function testSave()
|
||||||
{
|
{
|
||||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||||
$cacheItem->expects($this->once())->method('expiresAfter')->with(10);
|
$cacheItem->expects($this->exactly(2))->method('expiresAfter')->with(10);
|
||||||
|
|
||||||
$this->pool->expects($this->any())->method('getItem')->with(sha1('test'))->willReturn($cacheItem);
|
$this->pool->expects($this->any())->method('getItem')->with(sha1('test'))->willReturn($cacheItem);
|
||||||
$this->pool->expects($this->exactly(2))->method('save')->with($cacheItem);
|
$this->pool->expects($this->exactly(2))->method('save')->with($cacheItem);
|
||||||
|
|
||||||
$window = new Window('test', 10);
|
$window = new Window('test', 10, 20);
|
||||||
$this->storage->save($window);
|
$this->storage->save($window);
|
||||||
|
|
||||||
// test that expiresAfter is only called when getExpirationAt() does not return null
|
|
||||||
$window = unserialize(serialize($window));
|
$window = unserialize(serialize($window));
|
||||||
$this->storage->save($window);
|
$this->storage->save($window);
|
||||||
}
|
}
|
||||||
@ -47,7 +46,7 @@ class CacheStorageTest extends TestCase
|
|||||||
public function testFetchExistingState()
|
public function testFetchExistingState()
|
||||||
{
|
{
|
||||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||||
$window = new Window('test', 10);
|
$window = new Window('test', 10, 20);
|
||||||
$cacheItem->expects($this->any())->method('get')->willReturn($window);
|
$cacheItem->expects($this->any())->method('get')->willReturn($window);
|
||||||
$cacheItem->expects($this->any())->method('isHit')->willReturn(true);
|
$cacheItem->expects($this->any())->method('isHit')->willReturn(true);
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ namespace Symfony\Component\RateLimiter;
|
|||||||
/**
|
/**
|
||||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||||
*
|
*
|
||||||
|
* @internal
|
||||||
* @experimental in 5.2
|
* @experimental in 5.2
|
||||||
*/
|
*/
|
||||||
final class TokenBucket implements LimiterStateInterface
|
final class TokenBucket implements LimiterStateInterface
|
||||||
@ -32,6 +33,10 @@ final class TokenBucket implements LimiterStateInterface
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $id, int $initialTokens, Rate $rate, ?float $timer = null)
|
public function __construct(string $id, int $initialTokens, Rate $rate, ?float $timer = null)
|
||||||
{
|
{
|
||||||
|
if ($initialTokens < 1) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('Cannot set the limit of "%s" to 0, as that would never accept any hit.', TokenBucketLimiter::class));
|
||||||
|
}
|
||||||
|
|
||||||
$this->id = $id;
|
$this->id = $id;
|
||||||
$this->tokens = $this->burstSize = $initialTokens;
|
$this->tokens = $this->burstSize = $initialTokens;
|
||||||
$this->rate = $rate;
|
$this->rate = $rate;
|
||||||
|
@ -74,14 +74,16 @@ final class TokenBucketLimiter implements LimiterInterface
|
|||||||
$bucket->setTokens($availableTokens - $tokens);
|
$bucket->setTokens($availableTokens - $tokens);
|
||||||
$bucket->setTimer($now);
|
$bucket->setTimer($now);
|
||||||
|
|
||||||
$reservation = new Reservation($now);
|
$reservation = new Reservation($now, new Limit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true));
|
||||||
} else {
|
} else {
|
||||||
$remainingTokens = $tokens - $availableTokens;
|
$remainingTokens = $tokens - $availableTokens;
|
||||||
$waitDuration = $this->rate->calculateTimeForTokens($remainingTokens);
|
$waitDuration = $this->rate->calculateTimeForTokens($remainingTokens);
|
||||||
|
|
||||||
if (null !== $maxTime && $waitDuration > $maxTime) {
|
if (null !== $maxTime && $waitDuration > $maxTime) {
|
||||||
// process needs to wait longer than set interval
|
// 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));
|
$limit = new Limit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false);
|
||||||
|
|
||||||
|
throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// at $now + $waitDuration all tokens will be reserved for this process,
|
// at $now + $waitDuration all tokens will be reserved for this process,
|
||||||
@ -89,7 +91,7 @@ final class TokenBucketLimiter implements LimiterInterface
|
|||||||
$bucket->setTokens(0);
|
$bucket->setTokens(0);
|
||||||
$bucket->setTimer($now + $waitDuration);
|
$bucket->setTimer($now + $waitDuration);
|
||||||
|
|
||||||
$reservation = new Reservation($bucket->getTimer());
|
$reservation = new Reservation($bucket->getTimer(), new Limit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->storage->save($bucket);
|
$this->storage->save($bucket);
|
||||||
@ -105,18 +107,10 @@ final class TokenBucketLimiter implements LimiterInterface
|
|||||||
*/
|
*/
|
||||||
public function consume(int $tokens = 1): Limit
|
public function consume(int $tokens = 1): Limit
|
||||||
{
|
{
|
||||||
$bucket = $this->storage->fetch($this->id);
|
|
||||||
if (!$bucket instanceof TokenBucket) {
|
|
||||||
$bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate);
|
|
||||||
}
|
|
||||||
$now = microtime(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->reserve($tokens, 0);
|
return $this->reserve($tokens, 0)->getLimit();
|
||||||
|
|
||||||
return new Limit($bucket->getAvailableTokens($now) - $tokens, $this->rate->calculateNextTokenAvailability(), true);
|
|
||||||
} catch (MaxWaitDurationExceededException $e) {
|
} catch (MaxWaitDurationExceededException $e) {
|
||||||
return new Limit($bucket->getAvailableTokens($now), $this->rate->calculateNextTokenAvailability(), false);
|
return $e->getLimit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ namespace Symfony\Component\RateLimiter;
|
|||||||
/**
|
/**
|
||||||
* @author Wouter de Jong <wouter@wouterj.nl>
|
* @author Wouter de Jong <wouter@wouterj.nl>
|
||||||
*
|
*
|
||||||
|
* @internal
|
||||||
* @experimental in 5.2
|
* @experimental in 5.2
|
||||||
*/
|
*/
|
||||||
final class Window implements LimiterStateInterface
|
final class Window implements LimiterStateInterface
|
||||||
@ -21,11 +22,15 @@ final class Window implements LimiterStateInterface
|
|||||||
private $id;
|
private $id;
|
||||||
private $hitCount = 0;
|
private $hitCount = 0;
|
||||||
private $intervalInSeconds;
|
private $intervalInSeconds;
|
||||||
|
private $maxSize;
|
||||||
|
private $timer;
|
||||||
|
|
||||||
public function __construct(string $id, int $intervalInSeconds)
|
public function __construct(string $id, int $intervalInSeconds, int $windowSize, ?float $timer = null)
|
||||||
{
|
{
|
||||||
$this->id = $id;
|
$this->id = $id;
|
||||||
$this->intervalInSeconds = $intervalInSeconds;
|
$this->intervalInSeconds = $intervalInSeconds;
|
||||||
|
$this->maxSize = $windowSize;
|
||||||
|
$this->timer = $timer ?? microtime(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): string
|
public function getId(): string
|
||||||
@ -38,8 +43,15 @@ final class Window implements LimiterStateInterface
|
|||||||
return $this->intervalInSeconds;
|
return $this->intervalInSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add(int $hits = 1)
|
public function add(int $hits = 1, ?float $now = null)
|
||||||
{
|
{
|
||||||
|
$now = $now ?? microtime(true);
|
||||||
|
if (($now - $this->timer) > $this->intervalInSeconds) {
|
||||||
|
// reset window
|
||||||
|
$this->timer = $now;
|
||||||
|
$this->hitCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
$this->hitCount += $hits;
|
$this->hitCount += $hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,13 +60,37 @@ final class Window implements LimiterStateInterface
|
|||||||
return $this->hitCount;
|
return $this->hitCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAvailableTokens(float $now)
|
||||||
|
{
|
||||||
|
// if timer is in future, there are no tokens available anymore
|
||||||
|
if ($this->timer > $now) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if now is more than the window interval in the past, all tokens are available
|
||||||
|
if (($now - $this->timer) > $this->intervalInSeconds) {
|
||||||
|
return $this->maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->maxSize - $this->hitCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateTimeForTokens(int $tokens): int
|
||||||
|
{
|
||||||
|
if (($this->maxSize - $this->hitCount) >= $tokens) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cyclesRequired = ceil($tokens / $this->maxSize);
|
||||||
|
|
||||||
|
return $cyclesRequired * $this->intervalInSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
public function __sleep(): array
|
public function __sleep(): array
|
||||||
{
|
{
|
||||||
// $intervalInSeconds is not serialized, it should only be set
|
return ['id', 'hitCount', 'intervalInSeconds', 'timer', 'maxSize'];
|
||||||
// upon first creation of the Window.
|
|
||||||
return ['id', 'hitCount'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ namespace Symfony\Component\Security\Http\RateLimiter;
|
|||||||
|
|
||||||
use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter;
|
use Symfony\Component\HttpFoundation\RateLimiter\AbstractRequestRateLimiter;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\RateLimiter\Limiter;
|
use Symfony\Component\RateLimiter\RateLimiter;
|
||||||
use Symfony\Component\Security\Core\Security;
|
use Symfony\Component\Security\Core\Security;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,7 +31,7 @@ final class DefaultLoginRateLimiter extends AbstractRequestRateLimiter
|
|||||||
private $globalLimiter;
|
private $globalLimiter;
|
||||||
private $localLimiter;
|
private $localLimiter;
|
||||||
|
|
||||||
public function __construct(Limiter $globalLimiter, Limiter $localLimiter)
|
public function __construct(RateLimiter $globalLimiter, RateLimiter $localLimiter)
|
||||||
{
|
{
|
||||||
$this->globalLimiter = $globalLimiter;
|
$this->globalLimiter = $globalLimiter;
|
||||||
$this->localLimiter = $localLimiter;
|
$this->localLimiter = $localLimiter;
|
||||||
|
@ -14,7 +14,7 @@ namespace Symfony\Component\Security\Http\Tests\EventListener;
|
|||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\RateLimiter\Limiter;
|
use Symfony\Component\RateLimiter\RateLimiter;
|
||||||
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
|
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
|
||||||
@ -35,13 +35,13 @@ class LoginThrottlingListenerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->requestStack = new RequestStack();
|
$this->requestStack = new RequestStack();
|
||||||
|
|
||||||
$localLimiter = new Limiter([
|
$localLimiter = new RateLimiter([
|
||||||
'id' => 'login',
|
'id' => 'login',
|
||||||
'strategy' => 'fixed_window',
|
'strategy' => 'fixed_window',
|
||||||
'limit' => 3,
|
'limit' => 3,
|
||||||
'interval' => '1 minute',
|
'interval' => '1 minute',
|
||||||
], new InMemoryStorage());
|
], new InMemoryStorage());
|
||||||
$globalLimiter = new Limiter([
|
$globalLimiter = new RateLimiter([
|
||||||
'id' => 'login',
|
'id' => 'login',
|
||||||
'strategy' => 'fixed_window',
|
'strategy' => 'fixed_window',
|
||||||
'limit' => 6,
|
'limit' => 6,
|
||||||
|
Reference in New Issue
Block a user