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:
commit
aa661492d2
@ -25,17 +25,28 @@ final class CompoundLimiter implements LimiterInterface
|
||||
*/
|
||||
public function __construct(array $limiters)
|
||||
{
|
||||
if (!$limiters) {
|
||||
throw new \LogicException(sprintf('"%s::%s()" require at least one limiter.', self::class, __METHOD__));
|
||||
}
|
||||
$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) {
|
||||
$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
|
||||
|
@ -43,7 +43,7 @@ final class FixedWindowLimiter implements LimiterInterface
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function consume(int $tokens = 1): bool
|
||||
public function consume(int $tokens = 1): Limit
|
||||
{
|
||||
$this->lock->acquire(true);
|
||||
|
||||
@ -54,17 +54,28 @@ final class FixedWindowLimiter implements LimiterInterface
|
||||
}
|
||||
|
||||
$hitCount = $window->getHitCount();
|
||||
$availableTokens = $this->limit - $hitCount;
|
||||
$availableTokens = $this->getAvailableTokens($hitCount);
|
||||
$windowStart = \DateTimeImmutable::createFromFormat('U', time());
|
||||
if ($availableTokens < $tokens) {
|
||||
return false;
|
||||
return new Limit($availableTokens, $this->getRetryAfter($windowStart), false);
|
||||
}
|
||||
|
||||
$window->add($tokens);
|
||||
$this->storage->save($window);
|
||||
|
||||
return true;
|
||||
return new Limit($this->getAvailableTokens($window->getHitCount()), $this->getRetryAfter($windowStart), true);
|
||||
} finally {
|
||||
$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)));
|
||||
}
|
||||
}
|
||||
|
46
src/Symfony/Component/RateLimiter/Limit.php
Normal file
46
src/Symfony/Component/RateLimiter/Limit.php
Normal 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;
|
||||
}
|
||||
}
|
@ -24,7 +24,7 @@ interface LimiterInterface
|
||||
*
|
||||
* @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.
|
||||
|
@ -23,9 +23,9 @@ namespace Symfony\Component\RateLimiter;
|
||||
*/
|
||||
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
|
||||
|
@ -32,7 +32,7 @@ $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)) {
|
||||
if ($limiter->consume(1)->isAccepted()) {
|
||||
// ... execute the code
|
||||
}
|
||||
```
|
||||
|
@ -73,6 +73,16 @@ final class Rate
|
||||
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.
|
||||
*
|
||||
|
@ -38,19 +38,20 @@ class CompoundLimiterTest extends TestCase
|
||||
$limiter3 = $this->createLimiter(12, new \DateInterval('PT30S'));
|
||||
$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
|
||||
$limiter->consume(2);
|
||||
$this->assertTrue($limiter->consume(2)->isAccepted());
|
||||
|
||||
$this->assertTrue($limiter->consume());
|
||||
$this->assertFalse($limiter->consume(), 'Limiter 2 reached the limit');
|
||||
// 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));
|
||||
$this->assertFalse($limiter->consume(), 'Limiter 3 reached the limit');
|
||||
// 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());
|
||||
$this->assertTrue($limiter->consume()->isAccepted());
|
||||
}
|
||||
|
||||
private function createLimiter(int $limit, \DateInterval $interval): FixedWindowLimiter
|
||||
|
@ -40,8 +40,10 @@ class FixedWindowLimiterTest extends TestCase
|
||||
sleep(5);
|
||||
}
|
||||
|
||||
$this->assertTrue($limiter->consume());
|
||||
$this->assertFalse($limiter->consume());
|
||||
$limit = $limiter->consume();
|
||||
$this->assertTrue($limit->isAccepted());
|
||||
$limit = $limiter->consume();
|
||||
$this->assertFalse($limit->isAccepted());
|
||||
}
|
||||
|
||||
public function testConsumeOutsideInterval()
|
||||
@ -55,7 +57,9 @@ class FixedWindowLimiterTest extends TestCase
|
||||
$limiter->consume(9);
|
||||
// ...try bursting again at the start of the next window
|
||||
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
|
||||
|
@ -69,13 +69,21 @@ class TokenBucketLimiterTest extends TestCase
|
||||
|
||||
public function testConsume()
|
||||
{
|
||||
$limiter = $this->createLimiter();
|
||||
$rate = Rate::perSecond(10);
|
||||
$limiter = $this->createLimiter(10, $rate);
|
||||
|
||||
// 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
|
||||
$this->assertFalse($limiter->consume(10));
|
||||
$this->assertTrue($limiter->consume(5));
|
||||
$limit = $limiter->consume(10);
|
||||
$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)
|
||||
|
@ -103,14 +103,20 @@ final class TokenBucketLimiter implements LimiterInterface
|
||||
/**
|
||||
* {@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 {
|
||||
$this->reserve($tokens, 0);
|
||||
|
||||
return true;
|
||||
return new Limit($bucket->getAvailableTokens($now) - $tokens, $this->rate->calculateNextTokenAvailability(), true);
|
||||
} catch (MaxWaitDurationExceededException $e) {
|
||||
return false;
|
||||
return new Limit($bucket->getAvailableTokens($now), $this->rate->calculateNextTokenAvailability(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,7 +48,7 @@ final class LoginThrottlingListener implements EventSubscriberInterface
|
||||
$limiterKey = $this->createLimiterKey($username, $request);
|
||||
|
||||
$limiter = $this->limiter->create($limiterKey);
|
||||
if (!$limiter->consume()) {
|
||||
if (!$limiter->consume()->isAccepted()) {
|
||||
throw new TooManyLoginAttemptsAuthenticationException();
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user