Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
dec7fe2
feat: add `CalendarWindowLimiter`
Crovitche-1623 Oct 22, 2025
1a1523f
chore: cs
Crovitche-1623 Oct 22, 2025
22f09ea
chore: cs
Crovitche-1623 Oct 22, 2025
edda9c1
chore: compatibility with php < 8.4
Crovitche-1623 Oct 22, 2025
0fe0409
chore: cs
Crovitche-1623 Oct 22, 2025
433dbfd
fix: fix incorrect format for daily window type
Crovitche-1623 Oct 22, 2025
f9df2b4
chore: remove `declare(strict_types=1);`
Crovitche-1623 Oct 22, 2025
3de65ed
chore: remove `declare(strict_types=1);`
Crovitche-1623 Oct 22, 2025
eeeab00
chore: use `test` prefix instead of `#[Test]` attribute
Crovitche-1623 Oct 22, 2025
9275edc
chore: `CalendarWindowType` cannot be internal if it's used in config
Crovitche-1623 Oct 22, 2025
3990f9b
chore: more consistency with id
Crovitche-1623 Oct 22, 2025
60fc9a5
chore: symfony does not use `#[\Override]`
Crovitche-1623 Oct 22, 2025
788cefe
chore: rename `$clock` to `$timer` like the others rate limiters + re…
Crovitche-1623 Oct 22, 2025
f29045b
chore: use `calendar_interval` enum node instead of a free array.
Crovitche-1623 Oct 22, 2025
50959d6
chore: use an enum instead of a string to instantiate the `CalendarWi…
Crovitche-1623 Oct 22, 2025
2760252
chore: remove unnecessary return types for test
Crovitche-1623 Oct 22, 2025
d83555e
chore: rename `CalendarWindowType` to `CalendarInterval`
Crovitche-1623 Oct 22, 2025
3dd4074
feat: use a PSR-20 clock instead, so we can mock the time later.
Crovitche-1623 Oct 22, 2025
c4370b2
chore: cs --> ordered `use`
Crovitche-1623 Oct 22, 2025
9e105d4
refactor: set CalendarInterval enum as allowed type instead
Crovitche-1623 Oct 22, 2025
bf4bb79
refactor: hardcode values, so it'll work even if rate limiter compone…
Crovitche-1623 Oct 22, 2025
1244427
refactor: better names for CalendarInterval values
Crovitche-1623 Oct 22, 2025
dfeb411
refactor: missing type cast + typo + inject clock
Crovitche-1623 Oct 22, 2025
d53d3ec
refactor: nullable Clock to avoid a hard dependency
Crovitche-1623 Oct 23, 2025
e09efb4
refactor: inject clock service if available
Crovitche-1623 Oct 23, 2025
14b5965
refactor: try to fix static analysis (psalm)
Crovitche-1623 Oct 23, 2025
abf3c6b
tests: add missing test
Crovitche-1623 Oct 23, 2025
2ed15cc
refactor: typo `hour` instead of `hourl`
Crovitche-1623 Oct 23, 2025
f4d85a8
refactor: typo `hour` instead of `hourl`
Crovitche-1623 Oct 23, 2025
d8b19d1
feat: a window must be serializable to be stored
Crovitche-1623 Oct 23, 2025
a4f4e15
tests: mock ClockInterface without having to install Clock component
Crovitche-1623 Oct 24, 2025
541a9c1
refactor: cs + exception messages should end with a dot
Crovitche-1623 Oct 24, 2025
ebbc964
fix: incorrect `unserialize` + cs
Crovitche-1623 Oct 24, 2025
1aea879
tests: avoid #[Test] attribute
Crovitche-1623 Oct 24, 2025
3256901
build: add `psr/clock` because we need `ClockInterface`
Crovitche-1623 Oct 24, 2025
24bcfc0
fix: `reset` method + added few tests
Crovitche-1623 Oct 24, 2025
e67c2f8
build: moved the psr/clock to dev dependencies because ClockInterface…
Crovitche-1623 Oct 24, 2025
5959f7c
refactor: cs + readonly class
Crovitche-1623 Oct 24, 2025
7021eb2
fix: anonymous readonly classes are only available starting with PHP …
Crovitche-1623 Oct 24, 2025
4ed6edd
refactor: simplify code and added test to cover 100% of `CalendarWind…
Crovitche-1623 Oct 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2581,7 +2581,7 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $
->enumNode('policy')
->info('The algorithm to be used by this limiter.')
->isRequired()
->values(['fixed_window', 'token_bucket', 'sliding_window', 'compound', 'no_limit'])
->values(['fixed_window', 'token_bucket', 'sliding_window', 'compound', 'no_limit', 'calendar_window'])
->end()
->arrayNode('limiters', 'limiter')
->info('The limiter names to use when using the "compound" policy.')
Expand All @@ -2603,6 +2603,10 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $
->integerNode('amount')->info('Amount of tokens to add each interval.')->defaultValue(1)->end()
->end()
->end()
->enumNode('calendar_interval')
->info('Configures the chosen calendar interval if "policy" is set to "calendar_window".')
->values(['year', 'month', 'day', 'hour', 'minute'])
->end()
->end()
->validate()
->ifTrue(static fn ($v) => !\in_array($v['policy'], ['no_limit', 'compound'], true) && !isset($v['limit']))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
abstract_arg('config'),
abstract_arg('storage'),
null,
service('clock')->ignoreOnInvalid(),
])
;
};
5 changes: 5 additions & 0 deletions src/Symfony/Component/RateLimiter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.4
---

* Add `CalendarWindowLimiter`

7.3
---

Expand Down
28 changes: 28 additions & 0 deletions src/Symfony/Component/RateLimiter/Policy/CalendarInterval.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?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\Policy;

/**
* @author Thibault Gattolliat <contact@thibaultg.info>
*/
enum CalendarInterval: string
{
case YEAR = 'year';

case MONTH = 'month';

case DAY = 'day';

case HOUR = 'hour';

case MINUTE = 'minute';
}
129 changes: 129 additions & 0 deletions src/Symfony/Component/RateLimiter/Policy/CalendarWindow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?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\Policy;

use Symfony\Component\RateLimiter\LimiterStateInterface;

/**
* @author Thibault Gattolliat <contact@thibaultg.info>
*
* @internal
*/
final class CalendarWindow implements LimiterStateInterface
{
/**
* @var non-negative-int
*/
private int $hitCount = 0;

/**
* @param positive-int $size
*/
public function __construct(
private string $id,
private readonly CalendarInterval $calendarInterval,
private readonly int $size,
private readonly \DateTimeImmutable $timer,
) {
}

/**
* @return non-empty-string
*/
private function intervalToFormat(): string
{
return match ($this->calendarInterval) {
CalendarInterval::YEAR => 'Y',
CalendarInterval::MONTH => 'Y-m',
CalendarInterval::DAY => 'Y-m-d',
CalendarInterval::HOUR => 'Y-m-d\\TH',
CalendarInterval::MINUTE => 'Y-m-d\\TH-i',
};
}

/**
* @param positive-int $tokens
*/
public function add(int $tokens = 1): void
{
$this->hitCount += $tokens;
}

public function getAvailableTokens(): int
{
return $this->size - $this->hitCount;
}

public function calculateWaitTimeForTokens(int $tokens, \DateTimeInterface $now): int
{
if ($this->getAvailableTokens() >= $tokens) {
return 0;
}

return $this->getExpirationTime() - $now->getTimestamp();
}

/**
* @return non-empty-string
*/
public function getId(): string
{
return \sprintf('%s_%s', $this->id, $this->timer->format($this->intervalToFormat()));
}

/**
* @return non-negative-int
*/
public function getExpirationTime(): int
{
$expirationTime = match ($this->calendarInterval) {
CalendarInterval::YEAR => $this->timer->modify('last day of December this year 23:59:59.999999'),
CalendarInterval::MONTH => $this->timer->modify('last day of this month 23:59:59.999999'),
CalendarInterval::DAY => $this->timer->setTime(23, 59, 59, 999999),
CalendarInterval::HOUR => $this->timer->setTime(
(int) $this->timer->format('H'),
59,
59,
999999,
),
CalendarInterval::MINUTE => $this->timer->setTime(
(int) $this->timer->format('H'),
(int) $this->timer->format('i'),
59,
999999,
),
};

return $expirationTime->getTimestamp();
}

/**
* @return array<non-empty-string, positive-int>
*/
public function serialize(): array
{
if (0 === $this->hitCount) {
throw new \LogicException('A window with 0 hits should not be serialized.');
}

return [$this->getId() => $this->hitCount];
}

/**
* @param array<non-empty-string, positive-int> $data
*/
public function unserialize(array $data): void
{
$this->id = array_key_first($data);
$this->hitCount = $data[$this->id];
}
}
155 changes: 155 additions & 0 deletions src/Symfony/Component/RateLimiter/Policy/CalendarWindowLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?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\Policy;

use Psr\Clock\ClockInterface;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\RateLimiter\Exception\MaxWaitDurationExceededException;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\Reservation;
use Symfony\Component\RateLimiter\Storage\StorageInterface;

/**
* Limit the number of call on a calendar basis. This could be useful
* for instance if the numbers of call of a remote API is restricted on a
* monthly basis.
*
* Limitations: it'll work only with the gregorian calendar as PHP use it for
* its DateTime objects.
*
* @author Thibault Gattolliat <contact@thibaultg.info>
*/
final readonly class CalendarWindowLimiter implements LimiterInterface
{
private int $limit;

private \DateTimeImmutable $now;

public function __construct(
private string $id,
int $limit,
private CalendarInterval $calendarInterval,
private StorageInterface $storage,
private ?LockInterface $lock = null,
private ?ClockInterface $clock = null,
) {
if ($limit < 1) {
throw new \InvalidArgumentException(\sprintf('Cannot set the limit of "%s" lower than 1, as that would never accept any hit.', __CLASS__));
}

$this->limit = $limit;
$this->now = $this->clock?->now() ?? new \DateTimeImmutable();
}

public function reset(): void
{
try {
$this->lock?->acquire(true);

$id = $this->getWindowId($this->now);

$this->storage->delete($id);
} finally {
$this->lock?->release();
}
}

public function reserve(
int $tokens = 1,
?float $maxTime = null,
): Reservation {
if ($tokens > $this->limit) {
throw new \InvalidArgumentException(\sprintf('Cannot reserve more tokens (%d) than the size of the rate limiter (%d).', $tokens, $this->limit));
}

$this->lock?->acquire(true);

try {
$window = $this->getOrCreateWindow($this->now);

$availableTokens = $window->getAvailableTokens();

if (0 === $tokens) {
// Note: the wait duration can be zero.
$waitDuration = $window->calculateWaitTimeForTokens($tokens, $this->now);

$reservationWillStartAt = $this->now->add(new \DateInterval('PT'.$waitDuration.'S'));

return new Reservation($reservationWillStartAt->getTimestamp(), new RateLimit($availableTokens, $reservationWillStartAt, true, $this->limit));
}

if ($availableTokens >= $tokens) {
$window->add($tokens);

$reservation = new Reservation($this->now->getTimestamp(), new RateLimit($window->getAvailableTokens(), $this->now, true, $this->limit));

$this->storage->save($window);

return $reservation;
}

$waitDuration = $window->calculateWaitTimeForTokens($tokens, $this->now);

$reservationWillStartAt = $this->now->add(new \DateInterval('PT'.$waitDuration.'S'));

if (null !== $maxTime && $waitDuration > $maxTime) {
throw new MaxWaitDurationExceededException(\sprintf('The rate limiter wait time ("%d" seconds) is longer than the provided maximum time ("%d" seconds).', $waitDuration, $maxTime), new RateLimit($availableTokens, $reservationWillStartAt, false, $this->limit));
}

$window->add($tokens);

$reservation = new Reservation($reservationWillStartAt->getTimestamp(), new RateLimit($availableTokens, $reservationWillStartAt, false, $this->limit));

$this->storage->save($window);

return $reservation;
} finally {
$this->lock?->release();
}
}

public function consume(int $tokens = 1): RateLimit
{
try {
return $this->reserve($tokens, 0)->getRateLimit();
} catch (MaxWaitDurationExceededException $e) {
return $e->getRateLimit();
}
}

/**
* @return non-empty-string
*/
private function getWindowId(\DateTimeImmutable $timer): string
{
return (new CalendarWindow($this->id, $this->calendarInterval, $this->limit, $timer))->getId();
}

private function getOrCreateWindow(\DateTimeImmutable $timer): CalendarWindow
{
$windowId = $this->getWindowId($timer);

/**
* @var CalendarWindow|null $window
*/
$window = $this->storage->fetch($windowId);

if (null === $window) {
$window = new CalendarWindow($this->id, $this->calendarInterval, $this->limit, $timer);

$this->storage->save($window);
}

return $window;
}
}
Loading
Loading