[RateLimiter] rename Limit to RateLimit and add RateLimit::getLimit()

This commit is contained in:
Kevin Bond 2020-10-16 12:07:55 -04:00 committed by Fabien Potencier
parent 1d445cce63
commit c5361cfc58
16 changed files with 110 additions and 92 deletions

View File

@ -12,9 +12,9 @@
namespace Symfony\Component\HttpFoundation\RateLimiter; namespace Symfony\Component\HttpFoundation\RateLimiter;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\Limit;
use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\NoLimiter; use Symfony\Component\RateLimiter\NoLimiter;
use Symfony\Component\RateLimiter\RateLimit;
/** /**
* An implementation of RequestRateLimiterInterface that * An implementation of RequestRateLimiterInterface that
@ -26,23 +26,23 @@ use Symfony\Component\RateLimiter\NoLimiter;
*/ */
abstract class AbstractRequestRateLimiter implements RequestRateLimiterInterface abstract class AbstractRequestRateLimiter implements RequestRateLimiterInterface
{ {
public function consume(Request $request): Limit public function consume(Request $request): RateLimit
{ {
$limiters = $this->getLimiters($request); $limiters = $this->getLimiters($request);
if (0 === \count($limiters)) { if (0 === \count($limiters)) {
$limiters = [new NoLimiter()]; $limiters = [new NoLimiter()];
} }
$minimalLimit = null; $minimalRateLimit = null;
foreach ($limiters as $limiter) { foreach ($limiters as $limiter) {
$limit = $limiter->consume(1); $rateLimit = $limiter->consume(1);
if (null === $minimalLimit || $limit->getRemainingTokens() < $minimalLimit->getRemainingTokens()) { if (null === $minimalRateLimit || $rateLimit->getRemainingTokens() < $minimalRateLimit->getRemainingTokens()) {
$minimalLimit = $limit; $minimalRateLimit = $rateLimit;
} }
} }
return $minimalLimit; return $minimalRateLimit;
} }
public function reset(Request $request): void public function reset(Request $request): void

View File

@ -12,7 +12,7 @@
namespace Symfony\Component\HttpFoundation\RateLimiter; namespace Symfony\Component\HttpFoundation\RateLimiter;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\Limit; use Symfony\Component\RateLimiter\RateLimit;
/** /**
* A special type of limiter that deals with requests. * A special type of limiter that deals with requests.
@ -26,7 +26,7 @@ use Symfony\Component\RateLimiter\Limit;
*/ */
interface RequestRateLimiterInterface interface RequestRateLimiterInterface
{ {
public function consume(Request $request): Limit; public function consume(Request $request): RateLimit;
public function reset(Request $request): void; public function reset(Request $request): void;
} }

View File

@ -38,18 +38,18 @@ final class CompoundLimiter implements LimiterInterface
throw new ReserveNotSupportedException(__CLASS__); throw new ReserveNotSupportedException(__CLASS__);
} }
public function consume(int $tokens = 1): Limit public function consume(int $tokens = 1): RateLimit
{ {
$minimalLimit = null; $minimalRateLimit = null;
foreach ($this->limiters as $limiter) { foreach ($this->limiters as $limiter) {
$limit = $limiter->consume($tokens); $rateLimit = $limiter->consume($tokens);
if (null === $minimalLimit || $limit->getRemainingTokens() < $minimalLimit->getRemainingTokens()) { if (null === $minimalRateLimit || $rateLimit->getRemainingTokens() < $minimalRateLimit->getRemainingTokens()) {
$minimalLimit = $limit; $minimalRateLimit = $rateLimit;
} }
} }
return $minimalLimit; return $minimalRateLimit;
} }
public function reset(): void public function reset(): void

View File

@ -11,7 +11,7 @@
namespace Symfony\Component\RateLimiter\Exception; namespace Symfony\Component\RateLimiter\Exception;
use Symfony\Component\RateLimiter\Limit; use Symfony\Component\RateLimiter\RateLimit;
/** /**
* @author Wouter de Jong <wouter@wouterj.nl> * @author Wouter de Jong <wouter@wouterj.nl>
@ -20,17 +20,17 @@ use Symfony\Component\RateLimiter\Limit;
*/ */
class MaxWaitDurationExceededException extends \RuntimeException class MaxWaitDurationExceededException extends \RuntimeException
{ {
private $limit; private $rateLimit;
public function __construct(string $message, Limit $limit, int $code = 0, ?\Throwable $previous = null) public function __construct(string $message, RateLimit $rateLimit, int $code = 0, ?\Throwable $previous = null)
{ {
parent::__construct($message, $code, $previous); parent::__construct($message, $code, $previous);
$this->limit = $limit; $this->rateLimit = $rateLimit;
} }
public function getLimit(): Limit public function getRateLimit(): RateLimit
{ {
return $this->limit; return $this->rateLimit;
} }
} }

View File

@ -11,7 +11,7 @@
namespace Symfony\Component\RateLimiter\Exception; namespace Symfony\Component\RateLimiter\Exception;
use Symfony\Component\RateLimiter\Limit; use Symfony\Component\RateLimiter\RateLimit;
/** /**
* @author Kevin Bond <kevinbond@gmail.com> * @author Kevin Bond <kevinbond@gmail.com>
@ -20,27 +20,32 @@ use Symfony\Component\RateLimiter\Limit;
*/ */
class RateLimitExceededException extends \RuntimeException class RateLimitExceededException extends \RuntimeException
{ {
private $limit; private $rateLimit;
public function __construct(Limit $limit, $code = 0, \Throwable $previous = null) public function __construct(RateLimit $rateLimit, $code = 0, \Throwable $previous = null)
{ {
parent::__construct('Rate Limit Exceeded', $code, $previous); parent::__construct('Rate Limit Exceeded', $code, $previous);
$this->limit = $limit; $this->rateLimit = $rateLimit;
} }
public function getLimit(): Limit public function getRateLimit(): RateLimit
{ {
return $this->limit; return $this->rateLimit;
} }
public function getRetryAfter(): \DateTimeImmutable public function getRetryAfter(): \DateTimeImmutable
{ {
return $this->limit->getRetryAfter(); return $this->rateLimit->getRetryAfter();
} }
public function getRemainingTokens(): int public function getRemainingTokens(): int
{ {
return $this->limit->getRemainingTokens(); return $this->rateLimit->getRemainingTokens();
}
public function getLimit(): int
{
return $this->rateLimit->getLimit();
} }
} }

View File

@ -64,19 +64,19 @@ final class FixedWindowLimiter implements LimiterInterface
if ($availableTokens >= $tokens) { if ($availableTokens >= $tokens) {
$window->add($tokens); $window->add($tokens);
$reservation = new Reservation($now, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true)); $reservation = new Reservation($now, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->limit));
} else { } else {
$remainingTokens = $tokens - $availableTokens; $remainingTokens = $tokens - $availableTokens;
$waitDuration = $window->calculateTimeForTokens($remainingTokens); $waitDuration = $window->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), new Limit($window->getAvailableTokens($now), \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), new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
} }
$window->add($tokens); $window->add($tokens);
$reservation = new Reservation($now + $waitDuration, new Limit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false)); $reservation = new Reservation($now + $waitDuration, new RateLimit($window->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->limit));
} }
$this->storage->save($window); $this->storage->save($window);
} finally { } finally {
@ -89,12 +89,12 @@ final class FixedWindowLimiter implements LimiterInterface
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function consume(int $tokens = 1): Limit public function consume(int $tokens = 1): RateLimit
{ {
try { try {
return $this->reserve($tokens, 0)->getLimit(); return $this->reserve($tokens, 0)->getRateLimit();
} catch (MaxWaitDurationExceededException $e) { } catch (MaxWaitDurationExceededException $e) {
return $e->getLimit(); return $e->getRateLimit();
} }
} }

View File

@ -43,7 +43,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): Limit; public function consume(int $tokens = 1): RateLimit;
/** /**
* Resets the limit. * Resets the limit.

View File

@ -25,12 +25,12 @@ final class NoLimiter implements LimiterInterface
{ {
public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation public function reserve(int $tokens = 1, ?float $maxTime = null): Reservation
{ {
return new Reservation(time(), new Limit(\INF, new \DateTimeImmutable(), true)); return new Reservation(time(), new RateLimit(\INF, new \DateTimeImmutable(), true, \INF));
} }
public function consume(int $tokens = 1): Limit public function consume(int $tokens = 1): RateLimit
{ {
return new Limit(\INF, new \DateTimeImmutable(), true); return new RateLimit(\INF, new \DateTimeImmutable(), true, \INF);
} }
public function reset(): void public function reset(): void

View File

@ -18,17 +18,19 @@ use Symfony\Component\RateLimiter\Exception\RateLimitExceededException;
* *
* @experimental in 5.2 * @experimental in 5.2
*/ */
class Limit class RateLimit
{ {
private $availableTokens; private $availableTokens;
private $retryAfter; private $retryAfter;
private $accepted; private $accepted;
private $limit;
public function __construct(int $availableTokens, \DateTimeImmutable $retryAfter, bool $accepted) public function __construct(int $availableTokens, \DateTimeImmutable $retryAfter, bool $accepted, int $limit)
{ {
$this->availableTokens = $availableTokens; $this->availableTokens = $availableTokens;
$this->retryAfter = $retryAfter; $this->retryAfter = $retryAfter;
$this->accepted = $accepted; $this->accepted = $accepted;
$this->limit = $limit;
} }
public function isAccepted(): bool public function isAccepted(): bool
@ -58,6 +60,11 @@ class Limit
return $this->availableTokens; return $this->availableTokens;
} }
public function getLimit(): int
{
return $this->limit;
}
public function wait(): void public function wait(): void
{ {
sleep(($this->retryAfter->getTimestamp() - time()) * 1e6); sleep(($this->retryAfter->getTimestamp() - time()) * 1e6);

View File

@ -19,15 +19,15 @@ namespace Symfony\Component\RateLimiter;
final class Reservation final class Reservation
{ {
private $timeToAct; private $timeToAct;
private $limit; private $rateLimit;
/** /**
* @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, Limit $limit) public function __construct(float $timeToAct, RateLimit $rateLimit)
{ {
$this->timeToAct = $timeToAct; $this->timeToAct = $timeToAct;
$this->limit = $limit; $this->rateLimit = $rateLimit;
} }
public function getTimeToAct(): float public function getTimeToAct(): float
@ -40,9 +40,9 @@ final class Reservation
return max(0, (-microtime(true)) + $this->timeToAct); return max(0, (-microtime(true)) + $this->timeToAct);
} }
public function getLimit(): Limit public function getRateLimit(): RateLimit
{ {
return $this->limit; return $this->rateLimit;
} }
public function wait(): void public function wait(): void

View File

@ -76,7 +76,7 @@ final class SlidingWindowLimiter implements LimiterInterface
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function consume(int $tokens = 1): Limit public function consume(int $tokens = 1): RateLimit
{ {
$this->lock->acquire(true); $this->lock->acquire(true);
@ -91,13 +91,13 @@ final class SlidingWindowLimiter implements LimiterInterface
$hitCount = $window->getHitCount(); $hitCount = $window->getHitCount();
$availableTokens = $this->getAvailableTokens($hitCount); $availableTokens = $this->getAvailableTokens($hitCount);
if ($availableTokens < $tokens) { if ($availableTokens < $tokens) {
return new Limit($availableTokens, $window->getRetryAfter(), false); return new RateLimit($availableTokens, $window->getRetryAfter(), false, $this->limit);
} }
$window->add($tokens); $window->add($tokens);
$this->storage->save($window); $this->storage->save($window);
return new Limit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true); return new RateLimit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true, $this->limit);
} finally { } finally {
$this->lock->release(); $this->lock->release();
} }

View File

@ -41,10 +41,12 @@ class FixedWindowLimiterTest extends TestCase
sleep(5); sleep(5);
} }
$limit = $limiter->consume(); $rateLimit = $limiter->consume();
$this->assertTrue($limit->isAccepted()); $this->assertSame(10, $rateLimit->getLimit());
$limit = $limiter->consume(); $this->assertTrue($rateLimit->isAccepted());
$this->assertFalse($limit->isAccepted()); $rateLimit = $limiter->consume();
$this->assertFalse($rateLimit->isAccepted());
$this->assertSame(10, $rateLimit->getLimit());
} }
public function testConsumeOutsideInterval() public function testConsumeOutsideInterval()
@ -58,18 +60,18 @@ 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);
$limit = $limiter->consume(10); $rateLimit = $limiter->consume(10);
$this->assertEquals(0, $limit->getRemainingTokens()); $this->assertEquals(0, $rateLimit->getRemainingTokens());
$this->assertTrue($limit->isAccepted()); $this->assertTrue($rateLimit->isAccepted());
} }
public function testWrongWindowFromCache() public function testWrongWindowFromCache()
{ {
$this->storage->save(new DummyWindow()); $this->storage->save(new DummyWindow());
$limiter = $this->createLimiter(); $limiter = $this->createLimiter();
$limit = $limiter->consume(); $rateLimit = $limiter->consume();
$this->assertTrue($limit->isAccepted()); $this->assertTrue($rateLimit->isAccepted());
$this->assertEquals(9, $limit->getRemainingTokens()); $this->assertEquals(9, $rateLimit->getRemainingTokens());
} }
private function createLimiter(): FixedWindowLimiter private function createLimiter(): FixedWindowLimiter

View File

@ -13,25 +13,25 @@ namespace Symfony\Component\RateLimiter\Tests;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\RateLimiter\Exception\RateLimitExceededException; use Symfony\Component\RateLimiter\Exception\RateLimitExceededException;
use Symfony\Component\RateLimiter\Limit; use Symfony\Component\RateLimiter\RateLimit;
class LimitTest extends TestCase class RateLimitTest extends TestCase
{ {
public function testEnsureAcceptedDoesNotThrowExceptionIfAccepted() public function testEnsureAcceptedDoesNotThrowExceptionIfAccepted()
{ {
$limit = new Limit(10, new \DateTimeImmutable(), true); $rateLimit = new RateLimit(10, new \DateTimeImmutable(), true, 10);
$this->assertSame($limit, $limit->ensureAccepted()); $this->assertSame($rateLimit, $rateLimit->ensureAccepted());
} }
public function testEnsureAcceptedThrowsRateLimitExceptionIfNotAccepted() public function testEnsureAcceptedThrowsRateLimitExceptionIfNotAccepted()
{ {
$limit = new Limit(10, $retryAfter = new \DateTimeImmutable(), false); $rateLimit = new RateLimit(10, $retryAfter = new \DateTimeImmutable(), false, 10);
try { try {
$limit->ensureAccepted(); $rateLimit->ensureAccepted();
} catch (RateLimitExceededException $exception) { } catch (RateLimitExceededException $exception) {
$this->assertSame($limit, $exception->getLimit()); $this->assertSame($rateLimit, $exception->getRateLimit());
$this->assertSame(10, $exception->getRemainingTokens()); $this->assertSame(10, $exception->getRemainingTokens());
$this->assertSame($retryAfter, $exception->getRetryAfter()); $this->assertSame($retryAfter, $exception->getRetryAfter());

View File

@ -38,17 +38,19 @@ class SlidingWindowLimiterTest extends TestCase
$limiter->consume(8); $limiter->consume(8);
sleep(15); sleep(15);
$limit = $limiter->consume(); $rateLimit = $limiter->consume();
$this->assertTrue($limit->isAccepted()); $this->assertTrue($rateLimit->isAccepted());
$this->assertSame(10, $rateLimit->getLimit());
// We are 25% into the new window // We are 25% into the new window
$limit = $limiter->consume(5); $rateLimit = $limiter->consume(5);
$this->assertFalse($limit->isAccepted()); $this->assertFalse($rateLimit->isAccepted());
$this->assertEquals(3, $limit->getRemainingTokens()); $this->assertEquals(3, $rateLimit->getRemainingTokens());
sleep(13); sleep(13);
$limit = $limiter->consume(10); $rateLimit = $limiter->consume(10);
$this->assertTrue($limit->isAccepted()); $this->assertTrue($rateLimit->isAccepted());
$this->assertSame(10, $rateLimit->getLimit());
} }
public function testReserve() public function testReserve()

View File

@ -74,26 +74,28 @@ class TokenBucketLimiterTest extends TestCase
$limiter = $this->createLimiter(10, $rate); $limiter = $this->createLimiter(10, $rate);
// enough free tokens // enough free tokens
$limit = $limiter->consume(5); $rateLimit = $limiter->consume(5);
$this->assertTrue($limit->isAccepted()); $this->assertTrue($rateLimit->isAccepted());
$this->assertEquals(5, $limit->getRemainingTokens()); $this->assertEquals(5, $rateLimit->getRemainingTokens());
$this->assertEqualsWithDelta(time(), $limit->getRetryAfter()->getTimestamp(), 1); $this->assertEqualsWithDelta(time(), $rateLimit->getRetryAfter()->getTimestamp(), 1);
$this->assertSame(10, $rateLimit->getLimit());
// there are only 5 available free tokens left now // there are only 5 available free tokens left now
$limit = $limiter->consume(10); $rateLimit = $limiter->consume(10);
$this->assertEquals(5, $limit->getRemainingTokens()); $this->assertEquals(5, $rateLimit->getRemainingTokens());
$limit = $limiter->consume(5); $rateLimit = $limiter->consume(5);
$this->assertEquals(0, $limit->getRemainingTokens()); $this->assertEquals(0, $rateLimit->getRemainingTokens());
$this->assertEqualsWithDelta(time(), $limit->getRetryAfter()->getTimestamp(), 1); $this->assertEqualsWithDelta(time(), $rateLimit->getRetryAfter()->getTimestamp(), 1);
$this->assertSame(10, $rateLimit->getLimit());
} }
public function testWrongWindowFromCache() public function testWrongWindowFromCache()
{ {
$this->storage->save(new DummyWindow()); $this->storage->save(new DummyWindow());
$limiter = $this->createLimiter(); $limiter = $this->createLimiter();
$limit = $limiter->consume(); $rateLimit = $limiter->consume();
$this->assertTrue($limit->isAccepted()); $this->assertTrue($rateLimit->isAccepted());
$this->assertEquals(9, $limit->getRemainingTokens()); $this->assertEquals(9, $rateLimit->getRemainingTokens());
} }
private function createLimiter($initialTokens = 10, Rate $rate = null) private function createLimiter($initialTokens = 10, Rate $rate = null)

View File

@ -74,16 +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, new Limit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true)); $reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->maxBurst));
} 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
$limit = new Limit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false); $rateLimit = new RateLimit($availableTokens, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst);
throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $limit); throw new MaxWaitDurationExceededException(sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), $rateLimit);
} }
// at $now + $waitDuration all tokens will be reserved for this process, // at $now + $waitDuration all tokens will be reserved for this process,
@ -91,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(), new Limit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false)); $reservation = new Reservation($bucket->getTimer(), new RateLimit(0, \DateTimeImmutable::createFromFormat('U', floor($now + $waitDuration)), false, $this->maxBurst));
} }
$this->storage->save($bucket); $this->storage->save($bucket);
@ -105,12 +105,12 @@ final class TokenBucketLimiter implements LimiterInterface
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function consume(int $tokens = 1): Limit public function consume(int $tokens = 1): RateLimit
{ {
try { try {
return $this->reserve($tokens, 0)->getLimit(); return $this->reserve($tokens, 0)->getRateLimit();
} catch (MaxWaitDurationExceededException $e) { } catch (MaxWaitDurationExceededException $e) {
return $e->getLimit(); return $e->getRateLimit();
} }
} }
} }