Skip to content

[RateLimiter] Add RateLimiterBuilder #60084

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ CHANGELOG
the `#[AsController]` attribute is no longer required
* Deprecate setting the `framework.profiler.collect_serializer_data` config option to `false`
* Set `framework.rate_limiter.limiters.*.lock_factory` to `auto` by default
* Add `framework.rate_limiter.builder` option

7.2
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2499,6 +2499,24 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $
})
->end()
->children()
->arrayNode('builder')
->info('Configuration for the RateLimiterBuilder service.')
->addDefaultsIfNotSet()
->children()
->scalarNode('lock_factory')
->info('The service ID of the lock factory to use with the RateLimiterBuilder.')
->defaultValue('auto')
->end()
->scalarNode('cache_pool')
->info('The cache pool to use with RateLimiterBuilder.')
->defaultValue('cache.rate_limiter')
->end()
->scalarNode('storage_service')
->info('The service ID of a custom storage implementation, this precedes any configured "cache_pool".')
->defaultNull()
->end()
->end()
->end()
->arrayNode('limiters')
->useAttributeAsKey('name')
->arrayPrototype()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterBuilder;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\RateLimiter\Storage\CacheStorage;
Expand Down Expand Up @@ -3272,6 +3273,38 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde
$container->registerAliasForArgument($limiterId, RateLimiterFactoryInterface::class, $name.'.limiter');
}
}

if (class_exists(RateLimiterBuilder::class)) {
$builderConfig = $config['builder'];

if ('auto' === $builderConfig['lock_factory']) {
$builderConfig['lock_factory'] = $this->isInitializedConfigEnabled('lock') ? 'lock.factory' : null;
}

$builder = $container->getDefinition('limiter_builder');

if (null === $storageId = $builderConfig['storage_service'] ?? null) {
$container->register($storageId = 'limiter_builder.storage', CacheStorage::class)->addArgument(new Reference($builderConfig['cache_pool']));
}

$builder->replaceArgument(0, new Reference($storageId));

if ($builderConfig['lock_factory']) {
if (!interface_exists(LockInterface::class)) {
throw new LogicException('Rate Limiter Builder requires the Lock component to be installed. Try running "composer require symfony/lock".');
}

if (!$this->isInitializedConfigEnabled('lock')) {
throw new LogicException('Rate Limiter Builder requires the Lock component to be configured.');
}

$builder->replaceArgument(1, new Reference($builderConfig['lock_factory']));
}

$container->setAlias(RateLimiterBuilder::class, 'limiter_builder');
} else {
$container->removeDefinition('limiter_builder');
}
}

private function registerUidConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Symfony\Component\RateLimiter\RateLimiterBuilder;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\CacheStorage;

return static function (ContainerConfigurator $container) {
$container->services()
Expand All @@ -26,5 +28,11 @@
abstract_arg('storage'),
null,
])

->set('limiter_builder', RateLimiterBuilder::class)
->args([
abstract_arg('storage'),
null,
])
;
};
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,11 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor
'rate_limiter' => [
'enabled' => !class_exists(FullStack::class) && class_exists(TokenBucketLimiter::class),
'limiters' => [],
'builder' => [
'lock_factory' => 'auto',
'cache_pool' => 'cache.rate_limiter',
'storage_service' => null,
]
],
'uid' => [
'enabled' => !class_exists(FullStack::class) && class_exists(UuidFactory::class),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\RateLimiter\RateLimiterBuilder;
use Symfony\Component\Workflow\Exception\InvalidDefinitionException;

class PhpFrameworkExtensionTest extends FrameworkExtensionTestCase
Expand Down Expand Up @@ -290,4 +291,42 @@ public function testRateLimiterIsTagged()
$this->assertSame('first', $container->getDefinition('limiter.first')->getTag('rate_limiter')[0]['name']);
$this->assertSame('second', $container->getDefinition('limiter.second')->getTag('rate_limiter')[0]['name']);
}

public function testRateLimiterBuilderDefault()
{
if (!class_exists(RateLimiterBuilder::class)) {
$this->markTestSkipped('RateLimiterBuilder is not available.');
}

$container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'rate_limiter' => true,
]);
});

$this->assertSame('cache.rate_limiter', (string) $container->getDefinition('limiter_builder.storage')->getArgument(0));

$builder = $container->getDefinition('limiter_builder');
$this->assertSame('limiter_builder.storage', (string) $builder->getArgument(0));
$this->assertNull($builder->getArgument(1));

$this->assertSame('limiter_builder', (string) $container->getAlias(RateLimiterBuilder::class));
}

public function testRateLimiterBuilderDefaultWithLock()
{
if (!class_exists(RateLimiterBuilder::class)) {
$this->markTestSkipped('RateLimiterBuilder is not available.');
}

$container = $this->createContainerFromClosure(function (ContainerBuilder $container) {
$container->loadFromExtension('framework', [
'lock' => true,
'rate_limiter' => true,
]);
});

$builder = $container->getDefinition('limiter_builder');
$this->assertSame('lock.factory', (string) $builder->getArgument(1));
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/RateLimiter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CHANGELOG

* Add `RateLimiterFactoryInterface`
* Add `CompoundRateLimiterFactory`
* Add `RateLimiterBuilder`

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

use Symfony\Component\Lock\LockInterface;
use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter;
use Symfony\Component\RateLimiter\Policy\NoLimiter;
use Symfony\Component\RateLimiter\Policy\Rate;
use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use Symfony\Component\RateLimiter\Util\TimeUtil;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
class RateLimiterBuilder
{
public function __construct(
private StorageInterface $storage,
private ?LockInterface $lock = null,
) {
}

/**
* @param string $id Unique identifier for the rate limiter
* @param int $limit Maximum allowed hits for the passed interval
* @param \DateInterval|string $interval If string, must be a number followed by "second",
* "minute", "hour", "day", "week" or "month" (or their
* plural equivalent)
*/
public function slidingWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function slidingWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface
public function slidingWindow(string $id, int $limit, \DateInterval|string $interval): SlidingWindowLimiter

this is a bit a nitpick... but I like type correctness/safety where possible

this function is intended to create a slidingWindow or its specialization (yes the limiter classes are final so specialization is out-of-reach; but his might change in the future)

generalization here potentially allows slidingWindow to return any other Limiter policy in other RateLimiterBuilder specializations (consider marking this class as final) which is undesirable/unexpected... and might require upstream casting of return value if the concrete implementation method/property is required (currently not the case - all Limiter implementation have the same interface)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the implementation class name in the return type prevents us from doing any refactoring in the future (like returning a decorated implementation for instance) as widening a return type is a BC break.
For instance, we could imagine that in the future the returned limiters could be decorated with a traceable limiter in dev to be integrated with the profiler.

And having the same interface for all limiter implementations is the whole point of having a LimiterInterface as the main type in the component.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR
I'd suggest making this RateLimiterBuilder class final


Using the implementation class name in the return type prevents us from doing any refactoring in the future (like returning a decorated implementation for instance) as widening a return type is a BC break.

I see it as a way to prevent an unsafe change; when the type changes this communicates to upstream devs that they need to update their code.
Yes, changing this type is an explicit BC break, and that is actually good - it can be detected with static analyzers and in runtime type checking; using the interface type allows contributions that potentially introduce unexpected behavior in the runtime. Interface approach leaves the function name as the sole 'contract' - more of a phpDoc hint.

For instance, we could imagine that in the future the returned limiters could be decorated with a traceable limiter in dev to be integrated with the profiler.

This is still possible to do with expected implementation classes, but requires more work. It would require proxy class code generation (like Doctrine does, complicated), or creating tracing specializations of each limiter policy (does not scale well), or use of some AOP library (slow)...

And having the same interface for all limiter implementations is the whole point of having a LimiterInterface as the main type in the component.

Interface is preferably ok for

public function create(?string $key = null): LimiterInterface
as developer cannot make assumption on what concrete instance will be returned; arguably this is not the case for the RateLimiterBuilder's methods.


The contract for the methods of this class is type checked within this test

public function testCreateMethods()
{
$builder = new RateLimiterBuilder(new InMemoryStorage());
$this->assertInstanceOf(SlidingWindowLimiter::class, $builder->slidingWindow('foo', 5, '1 minute'));
$this->assertInstanceOf(FixedWindowLimiter::class, $builder->fixedWindow('foo', 5, '1 minute'));
$this->assertInstanceOf(TokenBucketLimiter::class, $builder->tokenBucket('foo', 5, Rate::perHour(2)));
$this->assertInstanceOf(CompoundLimiter::class, $builder->compound($this->createMock(LimiterInterface::class)));
$this->assertInstanceOf(NoLimiter::class, $builder->noop());
}

which is good, but this test cannot guarantee types returned by the methods of classes derived from the RateLimiterBuilder

Copy link
Member

@nicolas-grekas nicolas-grekas Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would go against the IoC principle, which we strive for. Symfony is a piece of reusable and extensible software, it has to provide flexibility and face the possibility that people will do silly things.

Closing the implementation like you're suggesting is on the contrary building on the idea that we should prevent end users from doing those silly things, at the cost of also preventing power users from doing what they want to achieve.

That's not the approach we have in this project as a whole. We always favor SOLID principles because they're empowering end users (nothing prevents silly things anyway.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, changing this type is an explicit BC break, and that is actually good

No it is not good, because it means it is actually forbidden to change this (due to our BC policy) and so we would not be able to add support for traceable limiters in the future (duplicating traceable logic to have implementations done using inheritance instead of composition is a no-go. We did things like that in the early days of Symfony and it was a maintenance nightmare)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with all due respect

This would go against the IoC principle, which we strive for. ...

IoC, here, is possible because this class does not follow SOLID principles:
S - breached - multiple classes instantiated
O[C] - not enforced - not open for extension (not implemented, do not confuse with class extends), not closed (this is class extends, and it is not forbidden)
L - not enforced - interface for return type makes breaching this one easy
I - not implemented
D - ok - made possible by compromises above

Closing the implementation like you're suggesting is ...

... in the favor of C in O[C] principle of SOLID

That's not the approach we have in this project as a whole. We always favor SOLID principles because they're empowering end users (nothing prevents silly things anyway.)

I agree, SOLID is a great design guide to follow (albeit class explosion and discovery), but this is a utility class and should probably be spared from 'power'-future-proofing.

I strongly believe that the architecture should favor both empowerment of users and prevention of invalid state (a.k.a. foot-guns or even CVEs)


(duplicating traceable logic to have implementations done using inheritance instead of composition is a no-go. We did things like that in the early days of Symfony and it was a maintenance nightmare)

This, however, is quite SOLID argument ;)

{
return new SlidingWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock);
}

/**
* @param string $id Unique identifier for the rate limiter
* @param int $limit Maximum allowed hits for the passed interval
* @param \DateInterval|string $interval If string, must be a number followed by "second",
* "minute", "hour", "day", "week" or "month" (or their
* plural equivalent)
*/
public function fixedWindow(string $id, int $limit, \DateInterval|string $interval): LimiterInterface
{
return new FixedWindowLimiter($id, $limit, TimeUtil::intervalNormalizer($interval), $this->storage, $this->lock);
}

/**
* @param string $id Unique identifier for the rate limiter
* @param int $maxBurst The maximum allowed hits in a burst
* @param Rate $rate The rate at which tokens are added to the bucket
*/
public function tokenBucket(string $id, int $maxBurst, Rate $rate): LimiterInterface
{
return new TokenBucketLimiter($id, $maxBurst, $rate, $this->storage, $this->lock);
}

public function compound(LimiterInterface ...$limiters): LimiterInterface
{
return new CompoundLimiter($limiters);
}

public function noop(): LimiterInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public function noop(): LimiterInterface
public function noLimit(): LimiterInterface

the policy name in config for NoLimiter is no_limit - it's nice to be consistent for discoverability purposes (personally, I like the noop name better)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yeah I really like noop too - especially since the other methods don't include "Limit" in their name.

Let's see what others think.

{
return new NoLimiter();
}
}
17 changes: 2 additions & 15 deletions src/Symfony/Component/RateLimiter/RateLimiterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use Symfony\Component\RateLimiter\Util\TimeUtil;

/**
* @author Wouter de Jong <wouter@wouterj.nl>
Expand Down Expand Up @@ -56,21 +57,7 @@ public function create(?string $key = null): LimiterInterface
private static function configureOptions(OptionsResolver $options): void
{
$intervalNormalizer = static function (Options $options, string $interval): \DateInterval {
// Create DateTimeImmutable from unix timesatmp, so the default timezone is ignored and we don't need to
// deal with quirks happening when modifying dates using a timezone with DST.
$now = \DateTimeImmutable::createFromFormat('U', time());

try {
$nowPlusInterval = @$now->modify('+'.$interval);
} catch (\DateMalformedStringException $e) {
throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e);
}

if (!$nowPlusInterval) {
throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval));
}

return $now->diff($nowPlusInterval);
return TimeUtil::intervalNormalizer($interval);
};

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

use PHPUnit\Framework\TestCase;
use Symfony\Component\RateLimiter\CompoundLimiter;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter;
use Symfony\Component\RateLimiter\Policy\NoLimiter;
use Symfony\Component\RateLimiter\Policy\Rate;
use Symfony\Component\RateLimiter\Policy\SlidingWindowLimiter;
use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter;
use Symfony\Component\RateLimiter\RateLimiterBuilder;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;

class RateLimiterBuilderTest extends TestCase
{
public function testCreateMethods()
{
$builder = new RateLimiterBuilder(new InMemoryStorage());

$this->assertInstanceOf(SlidingWindowLimiter::class, $builder->slidingWindow('foo', 5, '1 minute'));
$this->assertInstanceOf(FixedWindowLimiter::class, $builder->fixedWindow('foo', 5, '1 minute'));
$this->assertInstanceOf(TokenBucketLimiter::class, $builder->tokenBucket('foo', 5, Rate::perHour(2)));
$this->assertInstanceOf(CompoundLimiter::class, $builder->compound($this->createMock(LimiterInterface::class)));
$this->assertInstanceOf(NoLimiter::class, $builder->noop());
}
}
23 changes: 23 additions & 0 deletions src/Symfony/Component/RateLimiter/Util/TimeUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,27 @@ public static function dateIntervalToSeconds(\DateInterval $interval): int

return $now->add($interval)->getTimestamp() - $now->getTimestamp();
}

public static function intervalNormalizer(\DateInterval|string $interval): \DateInterval
{
if ($interval instanceof \DateInterval) {
return $interval;
}

// Create DateTimeImmutable from unix timestamp, so the default timezone is ignored and we don't need to
// deal with quirks happening when modifying dates using a timezone with DST.
$now = \DateTimeImmutable::createFromFormat('U', time());

try {
$nowPlusInterval = @$now->modify('+'.$interval);
} catch (\DateMalformedStringException $e) {
throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval), 0, $e);
}

if (!$nowPlusInterval) {
throw new \LogicException(\sprintf('Cannot parse interval "%s", please use a valid unit as described on https://www.php.net/datetime.formats.relative.', $interval));
}

return $now->diff($nowPlusInterval);
}
}
Loading