From 8f62afc5f986c2c732cc40d19e822a338b7649e3 Mon Sep 17 00:00:00 2001 From: Valentin Date: Sun, 27 Sep 2020 17:52:51 +0200 Subject: [PATCH] [RateLimiter] Return Limit object on Consume method --- .../Component/RateLimiter/CompoundLimiter.php | 19 ++++++-- .../RateLimiter/FixedWindowLimiter.php | 19 ++++++-- src/Symfony/Component/RateLimiter/Limit.php | 46 +++++++++++++++++++ .../RateLimiter/LimiterInterface.php | 2 +- .../Component/RateLimiter/NoLimiter.php | 4 +- src/Symfony/Component/RateLimiter/README.md | 2 +- src/Symfony/Component/RateLimiter/Rate.php | 10 ++++ .../RateLimiter/Tests/CompoundLimiterTest.php | 17 +++---- .../Tests/FixedWindowLimiterTest.php | 10 ++-- .../Tests/TokenBucketLimiterTest.php | 16 +++++-- .../RateLimiter/TokenBucketLimiter.php | 12 +++-- .../EventListener/LoginThrottlingListener.php | 2 +- 12 files changed, 128 insertions(+), 31 deletions(-) create mode 100644 src/Symfony/Component/RateLimiter/Limit.php diff --git a/src/Symfony/Component/RateLimiter/CompoundLimiter.php b/src/Symfony/Component/RateLimiter/CompoundLimiter.php index ad246bace378b..f931db51ef353 100644 --- a/src/Symfony/Component/RateLimiter/CompoundLimiter.php +++ b/src/Symfony/Component/RateLimiter/CompoundLimiter.php @@ -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 diff --git a/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php b/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php index f6ef8dd18b91f..00c8d405e0204 100644 --- a/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php +++ b/src/Symfony/Component/RateLimiter/FixedWindowLimiter.php @@ -43,7 +43,7 @@ public function __construct(string $id, int $limit, \DateInterval $interval, Sto /** * {@inheritdoc} */ - public function consume(int $tokens = 1): bool + public function consume(int $tokens = 1): Limit { $this->lock->acquire(true); @@ -54,17 +54,28 @@ public function consume(int $tokens = 1): bool } $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))); + } } diff --git a/src/Symfony/Component/RateLimiter/Limit.php b/src/Symfony/Component/RateLimiter/Limit.php new file mode 100644 index 0000000000000..48e822d580057 --- /dev/null +++ b/src/Symfony/Component/RateLimiter/Limit.php @@ -0,0 +1,46 @@ + + * + * 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 + * + * @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; + } +} diff --git a/src/Symfony/Component/RateLimiter/LimiterInterface.php b/src/Symfony/Component/RateLimiter/LimiterInterface.php index 3d610f714eb56..d768081594e96 100644 --- a/src/Symfony/Component/RateLimiter/LimiterInterface.php +++ b/src/Symfony/Component/RateLimiter/LimiterInterface.php @@ -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. diff --git a/src/Symfony/Component/RateLimiter/NoLimiter.php b/src/Symfony/Component/RateLimiter/NoLimiter.php index 720fda763dea8..3dfbabac0fbb8 100644 --- a/src/Symfony/Component/RateLimiter/NoLimiter.php +++ b/src/Symfony/Component/RateLimiter/NoLimiter.php @@ -23,9 +23,9 @@ */ 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 diff --git a/src/Symfony/Component/RateLimiter/README.md b/src/Symfony/Component/RateLimiter/README.md index c26bbb8a46420..0d4ff465e3980 100644 --- a/src/Symfony/Component/RateLimiter/README.md +++ b/src/Symfony/Component/RateLimiter/README.md @@ -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 } ``` diff --git a/src/Symfony/Component/RateLimiter/Rate.php b/src/Symfony/Component/RateLimiter/Rate.php index 9720c9ff4c199..009311e61262b 100644 --- a/src/Symfony/Component/RateLimiter/Rate.php +++ b/src/Symfony/Component/RateLimiter/Rate.php @@ -73,6 +73,16 @@ public function calculateTimeForTokens(int $tokens): int 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. * diff --git a/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php index ecf77e3718878..aab06fff3913b 100644 --- a/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/CompoundLimiterTest.php @@ -38,19 +38,20 @@ public function testConsume() $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 diff --git a/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php index f2b5095197bf0..025d93fada1ca 100644 --- a/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/FixedWindowLimiterTest.php @@ -40,8 +40,10 @@ public function testConsume() 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 @@ public function testConsumeOutsideInterval() $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 diff --git a/src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php b/src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php index 7c36f694bf775..77dce4d865899 100644 --- a/src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/TokenBucketLimiterTest.php @@ -69,13 +69,21 @@ public function testReserveMaxWaitingTime() 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) diff --git a/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php b/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php index df59e891cd606..d5a3364f7605d 100644 --- a/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php +++ b/src/Symfony/Component/RateLimiter/TokenBucketLimiter.php @@ -103,14 +103,20 @@ public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation /** * {@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); } } } diff --git a/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php index d45d879469d1f..85b46733bf868 100644 --- a/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/LoginThrottlingListener.php @@ -48,7 +48,7 @@ public function checkPassport(CheckPassportEvent $event): void $limiterKey = $this->createLimiterKey($username, $request); $limiter = $this->limiter->create($limiterKey); - if (!$limiter->consume()) { + if (!$limiter->consume()->isAccepted()) { throw new TooManyLoginAttemptsAuthenticationException(); } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy