Skip to content

Commit 19a3205

Browse files
committed
feature symfony#38565 [RateLimiter] Adding SlidingWindow algorithm (Nyholm)
This PR was squashed before being merged into the 5.x branch. Discussion ---------- [RateLimiter] Adding SlidingWindow algorithm | Q | A | ------------- | --- | Branch? | 5.x | Bug fix? | no | New feature? | yes | Deprecations? | | Tickets | | License | MIT | Doc PR | Should be added This is a draft PR to make sure we like the idea. I'll keep working on adding tests. Commits ------- c6d3b70 [RateLimiter] Adding SlidingWindow algorithm
2 parents 5bc26de + c6d3b70 commit 19a3205

File tree

7 files changed

+352
-3
lines changed

7 files changed

+352
-3
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,14 +1817,14 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode)
18171817
->enumNode('strategy')
18181818
->info('The rate limiting algorithm to use for this rate')
18191819
->isRequired()
1820-
->values(['fixed_window', 'token_bucket'])
1820+
->values(['fixed_window', 'token_bucket', 'sliding_window'])
18211821
->end()
18221822
->integerNode('limit')
18231823
->info('The maximum allowed hits in a fixed interval or burst')
18241824
->isRequired()
18251825
->end()
18261826
->scalarNode('interval')
1827-
->info('Configures the fixed interval if "strategy" is set to "fixed_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).')
1827+
->info('Configures the fixed interval if "strategy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).')
18281828
->end()
18291829
->arrayNode('rate')
18301830
->info('Configures the fill rate if "strategy" is set to "token_bucket"')
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter\Exception;
13+
14+
/**
15+
* @author Tobias Nyholm <[email protected]>
16+
*
17+
* @experimental in 5.2
18+
*/
19+
class InvalidIntervalException extends \LogicException
20+
{
21+
}

src/Symfony/Component/RateLimiter/Limiter.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ public function create(?string $key = null): LimiterInterface
5151
case 'fixed_window':
5252
return new FixedWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock);
5353

54+
case 'sliding_window':
55+
return new SlidingWindowLimiter($id, $this->config['limit'], $this->config['interval'], $this->storage, $lock);
56+
5457
default:
55-
throw new \LogicException(sprintf('Limiter strategy "%s" does not exists, it must be either "token_bucket" or "fixed_window".', $this->config['strategy']));
58+
throw new \LogicException(sprintf('Limiter strategy "%s" does not exists, it must be either "token_bucket", "sliding_window" or "fixed_window".', $this->config['strategy']));
5659
}
5760
}
5861

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter;
13+
14+
use Symfony\Component\RateLimiter\Exception\InvalidIntervalException;
15+
16+
/**
17+
* @author Tobias Nyholm <[email protected]>
18+
*
19+
* @experimental in 5.2
20+
*/
21+
final class SlidingWindow implements LimiterStateInterface
22+
{
23+
/**
24+
* @var string
25+
*/
26+
private $id;
27+
28+
/**
29+
* @var int
30+
*/
31+
private $hitCount = 0;
32+
33+
/**
34+
* @var int
35+
*/
36+
private $hitCountForLastWindow = 0;
37+
38+
/**
39+
* @var int how long a time frame is
40+
*/
41+
private $intervalInSeconds;
42+
43+
/**
44+
* @var int the unix timestamp when the current window ends
45+
*/
46+
private $windowEndAt;
47+
48+
/**
49+
* @var bool true if this window has been cached
50+
*/
51+
private $cached = true;
52+
53+
public function __construct(string $id, int $intervalInSeconds)
54+
{
55+
if ($intervalInSeconds < 1) {
56+
throw new InvalidIntervalException(sprintf('The interval must be positive integer, "%d" given.', $intervalInSeconds));
57+
}
58+
$this->id = $id;
59+
$this->intervalInSeconds = $intervalInSeconds;
60+
$this->windowEndAt = time() + $intervalInSeconds;
61+
$this->cached = false;
62+
}
63+
64+
public static function createFromPreviousWindow(self $window, int $intervalInSeconds): self
65+
{
66+
$new = new self($window->id, $intervalInSeconds);
67+
$new->hitCountForLastWindow = $window->hitCount;
68+
$new->windowEndAt = $window->windowEndAt + $intervalInSeconds;
69+
70+
return $new;
71+
}
72+
73+
/**
74+
* @internal
75+
*/
76+
public function __sleep(): array
77+
{
78+
// $cached is not serialized, it should only be set
79+
// upon first creation of the Window.
80+
return ['id', 'hitCount', 'intervalInSeconds', 'hitCountForLastWindow', 'windowEndAt'];
81+
}
82+
83+
public function getId(): string
84+
{
85+
return $this->id;
86+
}
87+
88+
/**
89+
* Store for the rest of this time frame and next.
90+
*/
91+
public function getExpirationTime(): ?int
92+
{
93+
if ($this->cached) {
94+
return null;
95+
}
96+
97+
return 2 * $this->intervalInSeconds;
98+
}
99+
100+
public function isExpired(): bool
101+
{
102+
return time() > $this->windowEndAt;
103+
}
104+
105+
public function add(int $hits = 1)
106+
{
107+
$this->hitCount += $hits;
108+
}
109+
110+
/**
111+
* Calculates the sliding window number of request.
112+
*/
113+
public function getHitCount(): int
114+
{
115+
$startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
116+
$percentOfCurrentTimeFrame = (time() - $startOfWindow) / $this->intervalInSeconds;
117+
118+
return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount);
119+
}
120+
121+
public function getRetryAfter(): \DateTimeImmutable
122+
{
123+
return \DateTimeImmutable::createFromFormat('U', $this->windowEndAt);
124+
}
125+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter;
13+
14+
use Symfony\Component\Lock\LockInterface;
15+
use Symfony\Component\Lock\NoLock;
16+
use Symfony\Component\RateLimiter\Storage\StorageInterface;
17+
use Symfony\Component\RateLimiter\Util\TimeUtil;
18+
19+
/**
20+
* The sliding window algorithm will look at your last window and the current one.
21+
* It is good algorithm to reduce bursts.
22+
*
23+
* Example:
24+
* Last time window we did 8 hits. We are currently 25% into
25+
* the current window. We have made 3 hits in the current window so far.
26+
* That means our sliding window hit count is (75% * 8) + 3 = 9.
27+
*
28+
* @author Tobias Nyholm <[email protected]>
29+
*
30+
* @experimental in 5.2
31+
*/
32+
final class SlidingWindowLimiter implements LimiterInterface
33+
{
34+
/**
35+
* @var string
36+
*/
37+
private $id;
38+
39+
/**
40+
* @var int
41+
*/
42+
private $limit;
43+
44+
/**
45+
* @var \DateInterval
46+
*/
47+
private $interval;
48+
49+
/**
50+
* @var StorageInterface
51+
*/
52+
private $storage;
53+
54+
/**
55+
* @var LockInterface|null
56+
*/
57+
private $lock;
58+
59+
use ResetLimiterTrait;
60+
61+
public function __construct(string $id, int $limit, \DateInterval $interval, StorageInterface $storage, ?LockInterface $lock = null)
62+
{
63+
$this->storage = $storage;
64+
$this->lock = $lock ?? new NoLock();
65+
$this->id = $id;
66+
$this->limit = $limit;
67+
$this->interval = TimeUtil::dateIntervalToSeconds($interval);
68+
}
69+
70+
/**
71+
* {@inheritdoc}
72+
*/
73+
public function consume(int $tokens = 1): Limit
74+
{
75+
$this->lock->acquire(true);
76+
77+
try {
78+
$window = $this->storage->fetch($this->id);
79+
if (!$window instanceof SlidingWindow) {
80+
$window = new SlidingWindow($this->id, $this->interval);
81+
} elseif ($window->isExpired()) {
82+
$window = SlidingWindow::createFromPreviousWindow($window, $this->interval);
83+
}
84+
85+
$hitCount = $window->getHitCount();
86+
$availableTokens = $this->getAvailableTokens($hitCount);
87+
if ($availableTokens < $tokens) {
88+
return new Limit($availableTokens, $window->getRetryAfter(), false);
89+
}
90+
91+
$window->add($tokens);
92+
$this->storage->save($window);
93+
94+
return new Limit($this->getAvailableTokens($window->getHitCount()), $window->getRetryAfter(), true);
95+
} finally {
96+
$this->lock->release();
97+
}
98+
}
99+
100+
private function getAvailableTokens(int $hitCount): int
101+
{
102+
return $this->limit - $hitCount;
103+
}
104+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Bridge\PhpUnit\ClockMock;
16+
use Symfony\Component\RateLimiter\SlidingWindowLimiter;
17+
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
18+
19+
/**
20+
* @group time-sensitive
21+
*/
22+
class SlidingWindowLimiterTest extends TestCase
23+
{
24+
private $storage;
25+
26+
protected function setUp(): void
27+
{
28+
$this->storage = new InMemoryStorage();
29+
30+
ClockMock::register(InMemoryStorage::class);
31+
}
32+
33+
public function testConsume()
34+
{
35+
$limiter = $this->createLimiter();
36+
37+
$limiter->consume(8);
38+
sleep(15);
39+
40+
$limit = $limiter->consume();
41+
$this->assertTrue($limit->isAccepted());
42+
43+
// We are 25% into the new window
44+
$limit = $limiter->consume(5);
45+
$this->assertFalse($limit->isAccepted());
46+
$this->assertEquals(3, $limit->getRemainingTokens());
47+
48+
sleep(13);
49+
$limit = $limiter->consume(10);
50+
$this->assertTrue($limit->isAccepted());
51+
}
52+
53+
private function createLimiter(): SlidingWindowLimiter
54+
{
55+
return new SlidingWindowLimiter('test', 10, new \DateInterval('PT12S'), $this->storage);
56+
}
57+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\RateLimiter\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\RateLimiter\Exception\InvalidIntervalException;
16+
use Symfony\Component\RateLimiter\SlidingWindow;
17+
18+
class SlidingWindowTest extends TestCase
19+
{
20+
public function testGetExpirationTime()
21+
{
22+
$window = new SlidingWindow('foo', 10);
23+
$this->assertSame(2 * 10, $window->getExpirationTime());
24+
$this->assertSame(2 * 10, $window->getExpirationTime());
25+
26+
$data = serialize($window);
27+
$cachedWindow = unserialize($data);
28+
$this->assertNull($cachedWindow->getExpirationTime());
29+
30+
$new = SlidingWindow::createFromPreviousWindow($cachedWindow, 15);
31+
$this->assertSame(2 * 15, $new->getExpirationTime());
32+
}
33+
34+
public function testInvalidInterval()
35+
{
36+
$this->expectException(InvalidIntervalException::class);
37+
new SlidingWindow('foo', 0);
38+
}
39+
}

0 commit comments

Comments
 (0)