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

Conversation

kbond
Copy link
Member

@kbond kbond commented Mar 29, 2025

Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? no
Issues Fix #51401 (comment)
License MIT

This is an alternative to #51403.

The idea here is to make it simpler to create one-off rate limiters. Currently, if you had 10 things you want to rate limit differently, you need to configure/inject 10 different rate limiters. This adds an option for a simpler approach: inject this builder and create your rate limiters on the fly.

If there's agreement on this feature, I'll add tests/configuration.

I'm not a huge fan of RateLimiterBuilder for the name but RateLimiterFactory is already taken. I'm open to alternatives.

Usage

class ApiController extends AbstractController
{
    public function registerUser(
        Request $request,
        RateLimiterBuilder $rateLimiterBuilder,
    ): Response {
        $limiter = $rateLimiterBuilder->slidingWindow(
            id: '...',
            limit: 10,
            interval: '10 minutes',
        );

        // ...
    }
}

TODO

  • Update changelog
  • Tests
  • FrameworkBundle configuration

@kbond kbond added RFC RFC = Request For Comments (proposals about features that you want to be discussed) DX DX = Developer eXperience (anything that improves the experience of using Symfony) labels Mar 29, 2025
@carsonbot carsonbot changed the title [RateLimiter] Add RateLimiterBuilder Add RateLimiterBuilder Mar 29, 2025
@carsonbot carsonbot added this to the 7.3 milestone Mar 29, 2025
@carsonbot carsonbot changed the title Add RateLimiterBuilder [RateLimiter] Add RateLimiterBuilder Mar 29, 2025
@kbond kbond force-pushed the rate-limiter/builder branch from 48b0607 to 16a7dd5 Compare March 30, 2025 14:01
@kbond kbond force-pushed the rate-limiter/builder branch 2 times, most recently from 3f5d2d9 to 6d69999 Compare April 2, 2025 23:45
@kbond kbond removed the RFC RFC = Request For Comments (proposals about features that you want to be discussed) label Apr 2, 2025
@kbond kbond force-pushed the rate-limiter/builder branch from 6d69999 to 224dbab Compare April 4, 2025 14:30
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.

* "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 ;)

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Apr 8, 2025

I'd prefer a solution that doesn't create a new sibling concept that's hard to grasp vs "factory".
Can't we have something like a wither API on the factory class?

@kbond
Copy link
Member Author

kbond commented Apr 8, 2025

Can't we have something like a wither API on the factory class?

Can you describe what you're thinking here a bit more? Feels like this ship has sailed as we now have 2 RateLimiterFactoryInterface implementations.

I guess you mean something like:

$factory->slidingWindow(...)->create(...);
$factory->fixedWindow(...)->create(...);

It wouldn't make sense to add these methods to the RateLimiterFactoryInterface (because $compoundFactory->slidingWindow()->create() doesn't make sense) - so it'd have to be another implementation... which sort of brings us back to the same problem. But I guess we could continue using the name *RateLimiterFactory - ManualRateLimiterFactory or CustomRateLimiterFactory maybe?

@kaznovac
Copy link
Contributor

kaznovac commented Apr 9, 2025

maybe something like this:

$factory->createBuilder(): RateLimiterBuilderInterface

interface RateLimiterBuilderInterface {
  function setLock(?LockInterface $lock): self; /** if desired to allow changing of factory provided services */
  function setStorage(StorageInterface $storage): self;

  function setId(string $id): self; /** or better setName */
  /** @param 'fixed_window'|'token_bucket'|'sliding_window'|'no_limit' $policy */
  function setPolicy(string $policy): self;

  function setLimit(int $limit): self;
  function setInterval(string $interval): self;
  function setRate(string $interval, int $amount): self; /** these can be noop where not-applicable to policy */

  function reset(): self; /** optional */

  function build(): LimiterInterface;
}

If you want to follow dogmatic Builder pattern - for each Limiter class a dedicated builder class should be crated; strenuous, but allows nice strategy pattern in the new factory's createBuilder(string $policy): RateLimiterBuilderInterface
(this aproach fully satisfies O - open for extension - new builders can be registered per policy, and factory is closed for modification as it's explicitly final; and typehint it is through phpstan conditional types)

Regarding CompoundLimiterBuilder, it can have addLimiter(LimiterInterface $limiter): self and just wrap all provided limiters in adequate CompoundLimiter instance on build.

@fabpot fabpot modified the milestones: 7.3, 7.4 May 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
DX DX = Developer eXperience (anything that improves the experience of using Symfony) Feature RateLimiter Status: Needs Review
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[RateLimiter] Use RateLimiterFactory with custom limit
7 participants