Skip to content

[RateLimiter] TokenBucket policy is not adding tokens if interval between requests is smaller than the interval provided in config #42627

@AdamKatzDev

Description

@AdamKatzDev

Symfony version(s) affected: tested on 5.2, but seems that this is not fixed in any newer verison (5.3, 5.4, 6.0).

Description
TokenBucket policy is not adding tokens if the interval between requests is smaller than the interval provided in config.

How to reproduce
Basic configuration is sufficient. Storage type doesn't matter. It does not matter if locks are configured or not.

Limit: 1200, Interval: 1 minute, Amount: 1200
If the interval between requests is less than 1 minute then the bucket is never getting replenished. You will get throttled eventually.

Limit: 1200, Interval: 1 seconds, Amount: 20
If the interval between requests is less than 1 second then the bucket won't get replenished.
If you make the requests a bit slower then you will see that the bucket is getting new tokens.

Possible Solution
No suggestions so far but I've tracked the cause of this.
My theory is that after every successful consume request the policy writes it's execution timestamp to storage:

$now = microtime(true);
$availableTokens = $bucket->getAvailableTokens($now);
if ($availableTokens >= $tokens) {
// tokens are now available, update bucket
$bucket->setTokens($availableTokens - $tokens);
$bucket->setTimer($now);
$reservation = new Reservation($now, new RateLimit($bucket->getAvailableTokens($now), \DateTimeImmutable::createFromFormat('U', floor($now)), true, $this->maxBurst));
} else {

The next request uses the saved timestamp and current timestamp to add new tokens to the bucket:
public function getAvailableTokens(float $now): int
{
$elapsed = $now - $this->timer;
return min($this->burstSize, $this->tokens + $this->rate->calculateNewTokensDuringInterval($elapsed));
}

/**
* Calculates the number of new free tokens during $duration.
*
* @param float $duration interval in seconds
*/
public function calculateNewTokensDuringInterval(float $duration): int
{
$cycles = floor($duration / TimeUtil::dateIntervalToSeconds($this->refillTime));
return $cycles * $this->refillAmount;
}

The $cycles variable will be 0 if $duration is less than $this->refillTime. If a user sets $this->refillTime in hours or days then the algorithm is not usable at all. If $this->refillTime is a few seconds then it can be somewhat usable in some conditions.

Additional context
In the end we've moved to sliding window policy (the burst limit was the same as fill rate anyway) and it looks like this.
User that is limited to 120 requests per minute (exceeds usage, gets throttled frequently):
image
image

User that is limited to 1200 requests per minute (shouldn't be throttled at all):
image
image

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions