8000 [RateLimiter] Added reserve() to LimiterInterface and rename Limiter to RateLimiter by wouterj · Pull Request #38562 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[RateLimiter] Added reserve() to LimiterInterface and rename Limiter to RateLimiter #38562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
[RateLimiter] Added reserve() to LimiterInterface and rename Limiter …
…to RateLimiter
  • Loading branch information
wouterj authored and fabpot committed Oct 16, 2020
commit cd34f2125423b6792c175528d90652ff29f525cf
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@
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\RateLimiter;
use Symfony\Component\RateLimiter\Storage\CacheStorage;
use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader;
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
Expand Down Expand Up @@ -2266,7 +2266,7 @@ public static function registerRateLimiter(ContainerBuilder $container, string $
$limiterConfig['id'] = $name;
$limiter->replaceArgument(0, $limiterConfig);

$container->registerAliasForArgument($limiterId, Limiter::class, $name.'.limiter');
$container->registerAliasForArgument($limiterId, RateLimiter::class, $name.'.limiter');
}

private function resolveTrustedHeaders(array $headers): int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Symfony\Component\RateLimiter\Limiter;
use Symfony\Component\RateLimiter\RateLimiter;

return static function (ContainerConfigurator $container) {
$container->services()
->set('cache.rate_limiter')
->parent('cache.app')
->tag('cache.pool')

->set('limiter', Limiter::class)
->set('limiter', RateLimiter::class)
->abstract()
->args([
abstract_arg('config'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
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\RateLimiter\DefaultLoginRateLimiter;

Expand Down Expand Up @@ -63,7 +63,7 @@ public function createAuthenticator(ContainerBuilder $container, string $firewal
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.');
}

Expand Down
7 changes: 7 additions & 0 deletions src/Symfony/Component/RateLimiter/CompoundLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace Symfony\Component\RateLimiter;

use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;

/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
Expand All @@ -31,6 +33,11 @@ public function __construct(array $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
{
$minimalLimit = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@

namespace Symfony\Component\RateLimiter\Exception;

use Symfony\Component\RateLimiter\Limit;

/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @experimental in 5.2
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
60 changes: 42 additions & 18 deletions src/Symfony/Component/RateLimiter/FixedWindowLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Lock\NoLock;
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use Symfony\Component\RateLimiter\Util\TimeUtil;

Expand All @@ -33,49 +34,72 @@ final class FixedWindowLimiter implements LimiterInterface

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->lock = $lock ?? new NoLock();
$this->id = $id;
$this->limit = $limit;
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
}

/**
* {@inheritdoc}
*/
public function consume(int $tokens = 1): Limit
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
{
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);

try {
$window = $this->storage->fetch($this->id);
if (!$window instanceof Window) {
$window = new Window($this->id, $this->interval);
$window = new Window($this->id, $this->interval, $this->limit);
}

$hitCount = $window->getHitCount();
$availableTokens = $this->getAvailableTokens($hitCount);
$windowStart = \DateTimeImmutable::createFromFormat('U', time());
if ($availableTokens < $tokens) {
return new Limit($availableTokens, $this->getRetryAfter($windowStart), false);
}
$now = microtime(true);
$availableTokens = $window->getAvailableTokens($now);
if ($availableTokens >= $tokens) {
$window->add($tokens);

$window->add($tokens);
$this->storage->save($window);
$reservation = new Reservation($now, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true));
} else {
$remainingTokens = $tokens - $availableTokens;
$waitDuration = $window->calculateTimeForTokens($remainingTokens);

return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true);
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);

$reservation = new Reservation($now + $waitDuration, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false));
}
$this->storage->save($window);
} finally {
$this->lock->release();
}

return $reservation;
}

public function getAvailableTokens(int $hitCount): int
/**
* {@inheritdoc}
*/
public function consume(int $tokens = 1): Limit
{
return $this->limit - $hitCount;
try {
return $this->reserve($tokens, 0)->getLimit();
} catch (MaxWaitDurationExceededException $e) {
return $e->getLimit();
}
}

private function getRetryAfter(\DateTimeImmutable $windowStart): \DateTimeImmutable
public function getAvailableTokens(int $hitCount): int
{
return $windowStart->add(new \DateInterval(sprintf('PT%sS', $this->interval)));
return $this->limit - $hitCount;
}
}
19 changes: 19 additions & 0 deletions src/Symfony/Component/RateLimiter/LimiterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,32 @@

namespace Symfony\Component\RateLimiter;

use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;

/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @experimental in 5.2
*/
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
* of tokens is unavailable.
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/RateLimiter/NoLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
*/
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
{
return new Limit(\INF, new \DateTimeImmutable(), true);
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/RateLimiter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ $ composer require symfony/rate-limiter

```php
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\RateLimiter\Limiter;
use Symfony\Component\RateLimiter\RateLimiter;

$limiter = new Limiter([
$limiter = new RateLimiter([
'id' => 'login',
'strategy' => 'token_bucket', // or 'fixed_window'
'limit' => 10,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*
* @experimental in 5.2
*/
final class Limiter
final class RateLimiter
{
private $config;
private $storage;
Expand Down
9 changes: 8 additions & 1 deletion src/Symfony/Component/RateLimiter/Reservation.php
1241
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
final class Reservation
{
private $timeToAct;
private $limit;

/**
* @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->limit = $limit;
}

public function getTimeToAct(): float
Expand All @@ -38,6 +40,11 @@ public function getWaitDuration(): float
return max(0, (-microtime(true)) + $this->timeToAct);
}

public function getLimit(): Limit
{
return $this->limit;
}

public function wait(): void
{
usleep($this->getWaitDuration() * 1e6);
Expand Down
6 changes: 6 additions & 0 deletions src/Symfony/Component/RateLimiter/SlidingWindowLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Lock\NoLock;
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use Symfony\Component\RateLimiter\Util\TimeUtil;

Expand Down Expand Up @@ -67,6 +68,11 @@ public function __construct(string $id, int $limit, \DateInterval $interval, Sto
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
}

public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
{
throw new ReserveNotSupportedException(__CLASS__);
}

/**
* {@inheritdoc}
*/
Expand Down
17 changes: 11 additions & 6 deletions src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\ClockMock;
use Symfony\Component\RateLimiter\CompoundLimiter;
use Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException;
use Symfony\Component\RateLimiter\FixedWindowLimiter;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;

Expand All @@ -38,22 +39,26 @@ public function testConsume()
$limiter3 = $this->createLimiter(12, new \DateInterval('PT30S'));
$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(3, $limiter->consume(5)->getRemainingTokens(), 'Limiter 1 reached the limit');
$this->assertEquals(0, $limiter->consume(4)->getRemainingTokens(), 'Limiter 1 reached the limit');
sleep(1); // reset limiter1's window
$this->assertTrue($limiter->consume(2)->isAccepted());
$this->assertTrue($limiter->consume(3)->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
sleep(10); // reset limiter2's window
$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 3 reached the limit');
sleep(20); // reset limiter3's window
$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
{
return new FixedWindowLimiter('test'.$limit, $limit, $interval, $this->storage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function testConsumeOutsideInterval()
sleep(10);
$limit = $limiter->consume(10);
$this->assertEquals(0, $limit->getRemainingTokens());
$this->assertEquals(time() + 60, $limit->getRetryAfter()->getTimestamp());
$this->assertTrue($limit->isAccepted());
}

public function testWrongWindowFromCache()
Expand Down
Loading
0