feature #38257 [RateLimiter] Add limit object on RateLimiter consume method (Valentin, vasilvestre)

This PR was merged into the 5.2-dev branch.

Discussion
----------

[RateLimiter] Add limit object on RateLimiter consume method

| Q             | A
| ------------- | ---
| Branch?       | master (should be merged in 5.2 before 31 September if possible)
| Bug fix?      | no
| New feature?  | yes <!-- please update src/**/CHANGELOG.md files -->
| Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tickets       | Fix #38241
| License       | MIT
| Doc PR        | Not yet :/ <!-- https://github.com/symfony/symfony-docs/pull/X -->

Commits
-------

8f62afc5f9 [RateLimiter] Return Limit object on Consume method
This commit is contained in:
Fabien Potencier 2020-09-30 07:47:32 +02:00
commit aa661492d2
12 changed files with 128 additions and 31 deletions

View File

@ -25,17 +25,28 @@ final class CompoundLimiter implements LimiterInterface
*/ */
public function __construct(array $limiters) public function __construct(array $limiters)
{ {
if (!$limiters) {
throw new \LogicException(sprintf('"%s::%s()" require at least one limiter.', self::class, __METHOD__));
}
$this->limiters = $limiters; $this->limiters = $limiters;
} }
public function consume(int $tokens = 1): bool public function consume(int $tokens = 1): Limit
{ {
$allow = true; $minimalLimit = null;
foreach ($this->limiters as $limiter) { foreach ($this->limiters as $limiter) {
$allow = $limiter->consume($tokens) && $allow; $limit = $limiter->consume($tokens);
if (0 === $limit->getRemainingTokens()) {
return $limit;
}
if (null === $minimalLimit || $limit->getRemainingTokens() < $minimalLimit->getRemainingTokens()) {
$minimalLimit = $limit;
}
} }
return $allow; return $minimalLimit;
} }
public function reset(): void public function reset(): void

View File

@ -43,7 +43,7 @@ final class FixedWindowLimiter implements LimiterInterface
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function consume(int $tokens = 1): bool public function consume(int $tokens = 1): Limit
{ {
$this->lock->acquire(true); $this->lock->acquire(true);
@ -54,17 +54,28 @@ final class FixedWindowLimiter implements LimiterInterface
} }
$hitCount = $window->getHitCount(); $hitCount = $window->getHitCount();
$availableTokens = $this->limit - $hitCount; $availableTokens = $this->getAvailableTokens($hitCount);
$windowStart = \DateTimeImmutable::createFromFormat('U', time());
if ($availableTokens < $tokens) { if ($availableTokens < $tokens) {
return false; return new Limit($availableTokens, $this->getRetryAfter($windowStart), false);
} }
$window->add($tokens); $window->add($tokens);
$this->storage->save($window); $this->storage->save($window);
return true; return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true);
} finally { } finally {
$this->lock->release(); $this->lock->release();
} }
} }
public function getAvailableTokens(int $hitCount): int
{
return $this->limit - $hitCount;
}
private function getRetryAfter(\DateTimeImmutable $windowStart): \DateTimeImmutable
{
return $windowStart->add(new \DateInterval(sprintf('PT%sS', $this->interval)));
}
} }

View File

@ -0,0 +1,46 @@
<?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 Valentin Silvestre <vsilvestre.pro@gmail.com>
*
* @experimental in 5.2
*/
class Limit
{
private $availableTokens;
private $retryAfter;
private $accepted;
public function __construct(int $availableTokens, \DateTimeImmutable $retryAfter, bool $accepted)
{
$this->availableTokens = $availableTokens;
$this->retryAfter = $retryAfter;
$this->accepted = $accepted;
}
public function isAccepted(): bool
{
return $this->accepted;
}
public function getRetryAfter(): \DateTimeImmutable
{
return $this->retryAfter;
}
public function getRemainingTokens(): int
{
return $this->availableTokens;
}
}

View File

@ -24,7 +24,7 @@ interface LimiterInterface
* *
* @param int $tokens the number of tokens required * @param int $tokens the number of tokens required
*/ */
public function consume(int $tokens = 1): bool; public function consume(int $tokens = 1): Limit;
/** /**
* Resets the limit. * Resets the limit.

View File

@ -23,9 +23,9 @@ namespace Symfony\Component\RateLimiter;
*/ */
final class NoLimiter implements LimiterInterface final class NoLimiter implements LimiterInterface
{ {
public function consume(int $tokens = 1): bool public function consume(int $tokens = 1): Limit
{ {
return true; return new Limit(\INF, new \DateTimeImmutable(), true, 'no_limit');
} }
public function reset(): void public function reset(): void

View File

@ -32,7 +32,7 @@ $limiter->reserve(1)->wait();
// ... execute the code // ... execute the code
// only claims 1 token if it's free at this moment (useful if you plan to skip this process) // only claims 1 token if it's free at this moment (useful if you plan to skip this process)
if ($limiter->consume(1)) { if ($limiter->consume(1)->isAccepted()) {
// ... execute the code // ... execute the code
} }
``` ```

View File

@ -73,6 +73,16 @@ final class Rate
return TimeUtil::dateIntervalToSeconds($this->refillTime) * $cyclesRequired; return TimeUtil::dateIntervalToSeconds($this->refillTime) * $cyclesRequired;
} }
/**
* Calculates the next moment of token availability.
*
* @return \DateTimeImmutable the next moment a token will be available
*/
public function calculateNextTokenAvailability(): \DateTimeImmutable
{
return (new \DateTimeImmutable())->add($this->refillTime);
}
/** /**
* Calculates the number of new free tokens during $duration. * Calculates the number of new free tokens during $duration.
* *

View File

@ -38,19 +38,20 @@ 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]);
$this->assertFalse($limiter->consume(5), 'Limiter 1 reached the limit'); // 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');
sleep(1); // reset limiter1's window sleep(1); // reset limiter1's window
$limiter->consume(2); $this->assertTrue($limiter->consume(2)->isAccepted());
$this->assertTrue($limiter->consume()); // Reach limiter 2 limit, verify that limiter2 available tokens reduced by 5 and and fetch successfully
$this->assertFalse($limiter->consume(), 'Limiter 2 reached the limit'); $this->assertEquals(0, $limiter->consume()->getRemainingTokens(), 'Limiter 2 has no remaining tokens left');
sleep(9); // reset limiter2's window sleep(9); // reset limiter2's window
$this->assertTrue($limiter->consume(3)->isAccepted());
$this->assertTrue($limiter->consume(3)); // Reach limiter 3 limit, verify that limiter2 available tokens reduced by 5 and fetch successfully
$this->assertFalse($limiter->consume(), '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());
} }
private function createLimiter(int $limit, \DateInterval $interval): FixedWindowLimiter private function createLimiter(int $limit, \DateInterval $interval): FixedWindowLimiter

View File

@ -40,8 +40,10 @@ class FixedWindowLimiterTest extends TestCase
sleep(5); sleep(5);
} }
$this->assertTrue($limiter->consume()); $limit = $limiter->consume();
$this->assertFalse($limiter->consume()); $this->assertTrue($limit->isAccepted());
$limit = $limiter->consume();
$this->assertFalse($limit->isAccepted());
} }
public function testConsumeOutsideInterval() public function testConsumeOutsideInterval()
@ -55,7 +57,9 @@ class FixedWindowLimiterTest extends TestCase
$limiter->consume(9); $limiter->consume(9);
// ...try bursting again at the start of the next window // ...try bursting again at the start of the next window
sleep(10); sleep(10);
$this->assertTrue($limiter->consume(10)); $limit = $limiter->consume(10);
$this->assertEquals(0, $limit->getRemainingTokens());
$this->assertEquals(time() + 60, $limit->getRetryAfter()->getTimestamp());
} }
private function createLimiter(): FixedWindowLimiter private function createLimiter(): FixedWindowLimiter

View File

@ -69,13 +69,21 @@ class TokenBucketLimiterTest extends TestCase
public function testConsume() public function testConsume()
{ {
$limiter = $this->createLimiter(); $rate = Rate::perSecond(10);
$limiter = $this->createLimiter(10, $rate);
// enough free tokens // enough free tokens
$this->assertTrue($limiter->consume(5)); $limit = $limiter->consume(5);
$this->assertTrue($limit->isAccepted());
$this->assertEquals(5, $limit->getRemainingTokens());
$this->assertEqualsWithDelta(time(), $limit->getRetryAfter()->getTimestamp(), 1);
// there are only 5 available free tokens left now // there are only 5 available free tokens left now
$this->assertFalse($limiter->consume(10)); $limit = $limiter->consume(10);
$this->assertTrue($limiter->consume(5)); $this->assertEquals(5, $limit->getRemainingTokens());
$limit = $limiter->consume(5);
$this->assertEquals(0, $limit->getRemainingTokens());
$this->assertEqualsWithDelta(time(), $limit->getRetryAfter()->getTimestamp(), 1);
} }
private function createLimiter($initialTokens = 10, Rate $rate = null) private function createLimiter($initialTokens = 10, Rate $rate = null)

View File

@ -103,14 +103,20 @@ final class TokenBucketLimiter implements LimiterInterface
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function consume(int $tokens = 1): bool public function consume(int $tokens = 1): Limit
{ {
$bucket = $this->storage->fetch($this->id);
if (null === $bucket) {
$bucket = new TokenBucket($this->id, $this->maxBurst, $this->rate);
}
$now = microtime(true);
try { try {
$this->reserve($tokens, 0); $this->reserve($tokens, 0);
return true; return new Limit($bucket->getAvailableTokens($now) - $tokens, $this->rate->calculateNextTokenAvailability(), true);
} catch (MaxWaitDurationExceededException $e) { } catch (MaxWaitDurationExceededException $e) {
return false; return new Limit($bucket->getAvailableTokens($now), $this->rate->calculateNextTokenAvailability(), false);
} }
} }
} }

View File

@ -48,7 +48,7 @@ final class LoginThrottlingListener implements EventSubscriberInterface
$limiterKey = $this->createLimiterKey($username, $request); $limiterKey = $this->createLimiterKey($username, $request);
$limiter = $this->limiter->create($limiterKey); $limiter = $this->limiter->create($limiterKey);
if (!$limiter->consume()) { if (!$limiter->consume()->isAccepted()) {
throw new TooManyLoginAttemptsAuthenticationException(); throw new TooManyLoginAttemptsAuthenticationException();
} }
} }