Skip to content

TokenBucketRateLimiter: AttemptAcquire(0) succeeds while CurrentAvailablePermits is 0 due to fractional _tokenCount #118192

@AdamCLarsen

Description

@AdamCLarsen

Description

_tokenCount is stored as double and may hold fractional values after replenishment. AttemptAcquire(0) returns a successful lease whenever _tokenCount > 0, yet GetStatistics().CurrentAvailablePermits truncates the same value to long, so it reports 0.

This means that AttemptAcquire(0) will always grants a lease, even when none should be given.

https://github.com/dotnet/runtime/blob/b6e34b8c14fc531b6997795024374302447372d7/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiter.cs#L115C13-L119C18

Reproduction

[Test]
public async Task FractionalTokenBug()
{
    var limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
    {
        TokenLimit = 3,
        TokensPerPeriod = 1,
        ReplenishmentPeriod = TimeSpan.FromSeconds(0.5),
        QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
        AutoReplenishment = false
    });

    // Drain bucket
    limiter.AttemptAcquire(3).Dispose();

    await Task.Delay(500);   // enough to add ~1.0 token
    limiter.TryReplenish();  // _tokenCount ≈ 1.0

    limiter.AttemptAcquire(1).Dispose();      // succeeds as expected
    Assert.That(limiter.GetStatistics()?.CurrentAvailablePermits, Is.EqualTo(0), "Tokens after acquiring 1 permit");
    var lease = limiter.AttemptAcquire(0);    // **unexpected success**
    Assert.That(!lease.IsAcquired, "Acquired a lease, when none should be available");
    lease.Dispose();
}

Resutls

Assert.That(!lease.IsAcquired, "Acquired a lease, when none should be available"); is failing.

Setting a break point, showed the value of _tokenCount to be 0.025640600000000013 in one example test run.

Root cause

  • GetStatistics() truncates with (long)_tokenCount, so shows as 0.
  • AttemptAcquireCore considers any _tokenCount > 0 a success, even when _tokenCount sits in (0,1)

The cast hides the available fraction from statistics but the acquisition path still sees it, there are multiple places in the class that should be updated to _tokenCount >= 1, or where conditions are checking for _tokenCount == 0, that will never happen once the first TryReplenish triggers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions