Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public sealed class FixedWindowRateLimiter : ReplenishingRateLimiter
private static readonly RateLimitLease SuccessfulLease = new FixedWindowLease(true, null);
private static readonly RateLimitLease FailedLease = new FixedWindowLease(false, null);

// System.Threading.Timer truncates its period to whole milliseconds; a sub-millisecond period
// becomes 0 and disables periodic firing. This is the minimum period used for the replenishment timer.
private static readonly TimeSpan s_minTimerPeriod = TimeSpan.FromMilliseconds(1);

/// <inheritdoc />
public override TimeSpan? IdleDuration => RateLimiterHelper.GetElapsedTime(_idleSince);

Expand Down Expand Up @@ -82,7 +86,12 @@ public FixedWindowRateLimiter(FixedWindowRateLimiterOptions options)

if (_options.AutoReplenishment)
{
_renewTimer = new Timer(Replenish, this, _options.Window, _options.Window);
// System.Threading.Timer truncates the period to whole milliseconds, so a sub-millisecond
// window becomes 0 and the timer never fires periodically, silently stopping replenishment.
// Clamp the timer period to a 1ms floor so auto-replenishment keeps running. Each tick still
// refreshes the full window, so the limiter is at worst slightly more restrictive.
TimeSpan timerPeriod = _options.Window < s_minTimerPeriod ? s_minTimerPeriod : _options.Window;
_renewTimer = new Timer(Replenish, this, timerPeriod, timerPeriod);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed class FixedWindowRateLimiterOptions
/// <summary>
/// Specifies the time window that takes in the requests.
/// Must be set to a value greater than <see cref="TimeSpan.Zero" /> by the time these options are passed to the constructor of <see cref="FixedWindowRateLimiter"/>.
/// When <see cref="AutoReplenishment" /> is <see langword="true" /> and this value is less than 1 millisecond, a period of 1 millisecond is used for the internal replenishment timer instead, as <see cref="System.Threading.Timer"/> does not support sub-millisecond periods.
/// </summary>
public TimeSpan Window { get; set; } = TimeSpan.Zero;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public sealed class SlidingWindowRateLimiter : ReplenishingRateLimiter
private static readonly RateLimitLease SuccessfulLease = new SlidingWindowLease(true, null);
private static readonly RateLimitLease FailedLease = new SlidingWindowLease(false, null);

// System.Threading.Timer truncates its period to whole milliseconds; a sub-millisecond period
// becomes 0 and disables periodic firing. This is the minimum period used for the replenishment timer.
private static readonly TimeSpan s_minTimerPeriod = TimeSpan.FromMilliseconds(1);

/// <inheritdoc />
public override TimeSpan? IdleDuration => RateLimiterHelper.GetElapsedTime(_idleSince);

Expand Down Expand Up @@ -89,7 +93,12 @@ public SlidingWindowRateLimiter(SlidingWindowRateLimiterOptions options)

if (_options.AutoReplenishment)
{
_renewTimer = new Timer(Replenish, this, ReplenishmentPeriod, ReplenishmentPeriod);
// ReplenishmentPeriod is derived as Window / SegmentsPerWindow, so it can be sub-millisecond even
// when Window is not. System.Threading.Timer truncates the period to whole milliseconds, so a
// sub-millisecond period becomes 0 and the timer never fires periodically, silently stopping
// replenishment. Clamp the timer period to a 1ms floor so auto-replenishment keeps running.
TimeSpan timerPeriod = ReplenishmentPeriod < s_minTimerPeriod ? s_minTimerPeriod : ReplenishmentPeriod;
_renewTimer = new Timer(Replenish, this, timerPeriod, timerPeriod);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed class SlidingWindowRateLimiterOptions
/// <summary>
/// Specifies the minimum period between replenishments.
/// Must be set to a value greater than <see cref="TimeSpan.Zero" /> by the time these options are passed to the constructor of <see cref="SlidingWindowRateLimiter"/>.
/// The internal replenishment timer fires every <see cref="Window" /> divided by <see cref="SegmentsPerWindow" />. When <see cref="AutoReplenishment" /> is <see langword="true" /> and that derived period is less than 1 millisecond, a period of 1 millisecond is used instead, as <see cref="System.Threading.Timer"/> does not support sub-millisecond periods.
/// </summary>
public TimeSpan Window { get; set; } = TimeSpan.Zero;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public sealed class TokenBucketRateLimiter : ReplenishingRateLimiter
private static readonly RateLimitLease SuccessfulLease = new TokenBucketLease(true, null);
private static readonly RateLimitLease FailedLease = new TokenBucketLease(false, null);

// System.Threading.Timer truncates its period to whole milliseconds; a sub-millisecond period
// becomes 0 and disables periodic firing. This is the minimum period used for the replenishment timer.
private static readonly TimeSpan s_minTimerPeriod = TimeSpan.FromMilliseconds(1);

/// <inheritdoc />
public override TimeSpan? IdleDuration => RateLimiterHelper.GetElapsedTime(_idleSince);

Expand Down Expand Up @@ -83,7 +87,13 @@ public TokenBucketRateLimiter(TokenBucketRateLimiterOptions options)

if (_options.AutoReplenishment)
{
_renewTimer = new Timer(Replenish, this, _options.ReplenishmentPeriod, _options.ReplenishmentPeriod);
// System.Threading.Timer truncates the period to whole milliseconds, so a sub-millisecond
// period becomes 0 and the timer never fires periodically, silently stopping replenishment.
// Clamp the timer period to a 1ms floor so auto-replenishment keeps running. The replenishment
// amount is unchanged: each tick still adds TokensPerPeriod, so the limiter is at worst slightly
// more restrictive than a sub-millisecond period would have been.
TimeSpan timerPeriod = _options.ReplenishmentPeriod < s_minTimerPeriod ? s_minTimerPeriod : _options.ReplenishmentPeriod;
_renewTimer = new Timer(Replenish, this, timerPeriod, timerPeriod);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed class TokenBucketRateLimiterOptions
/// <summary>
/// Specifies the minimum period between replenishments.
/// Must be set to a value greater than <see cref="TimeSpan.Zero" /> by the time these options are passed to the constructor of <see cref="TokenBucketRateLimiter"/>.
/// When <see cref="AutoReplenishment" /> is <see langword="true" /> and this value is less than 1 millisecond, a period of 1 millisecond is used for the internal replenishment timer instead, as <see cref="System.Threading.Timer"/> does not support sub-millisecond periods.
/// </summary>
public TimeSpan ReplenishmentPeriod { get; set; } = TimeSpan.Zero;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,28 @@ public void ReplenishingRateLimiterPropertiesHaveCorrectValues()
Assert.Equal(replenishPeriod, limiter2.ReplenishmentPeriod);
}

[Fact]
public void SubMillisecondWindowWithAutoReplenishmentDoesNotDisableTimer()
{
// Regression test for https://github.com/dotnet/runtime/issues/109027
// System.Threading.Timer truncates its period to whole milliseconds, so a sub-millisecond
// Window previously produced a timer period of 0, which fires once and never again,
// silently stopping auto-replenishment. The timer period is now clamped to a 1ms floor.
var subMillisecond = TimeSpan.FromTicks(TimeSpan.TicksPerMillisecond / 2); // 500 microseconds
using ReplenishingRateLimiter limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
Window = subMillisecond,
AutoReplenishment = true
});

// The clamp affects only the internal timer; the observable ReplenishmentPeriod still reflects the configured Window.
Assert.True(limiter.IsAutoReplenishing);
Assert.Equal(subMillisecond, limiter.ReplenishmentPeriod);
}

[Fact]
public async Task RetryAfterWithPartiallyElapsedWindow()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,45 @@ public void ReplenishingRateLimiterPropertiesHaveCorrectValues()
Assert.Equal(replenishPeriod, limiter2.ReplenishmentPeriod);
}

[Fact]
public void SubMillisecondReplenishmentPeriodWithAutoReplenishmentDoesNotDisableTimer()
{
// Regression test for https://github.com/dotnet/runtime/issues/109027
// The effective replenishment period is Window / SegmentsPerWindow, so it can be sub-millisecond
// even when Window is not. System.Threading.Timer truncates its period to whole milliseconds, so a
// sub-millisecond period previously produced a timer period of 0, which fires once and never again,
// silently stopping auto-replenishment. The timer period is now clamped to a 1ms floor.

// Case 1: Window itself is sub-millisecond.
var subMillisecondWindow = TimeSpan.FromTicks(TimeSpan.TicksPerMillisecond / 2); // 500 microseconds
using ReplenishingRateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
{
PermitLimit = 10,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
Window = subMillisecondWindow,
SegmentsPerWindow = 1,
AutoReplenishment = true
});
Assert.True(limiter.IsAutoReplenishing);
Assert.Equal(subMillisecondWindow, limiter.ReplenishmentPeriod);

// Case 2: Window is >= 1ms, but Window / SegmentsPerWindow is sub-millisecond (5ms / 10 = 500us).
var window = TimeSpan.FromMilliseconds(5);
using ReplenishingRateLimiter limiter2 = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
{
PermitLimit = 10,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
Window = window,
SegmentsPerWindow = 10,
AutoReplenishment = true
});
Assert.True(limiter2.IsAutoReplenishing);
// The clamp affects only the internal timer; the observable derived ReplenishmentPeriod is unchanged.
Assert.Equal(new TimeSpan(window.Ticks / 10), limiter2.ReplenishmentPeriod);
}

[Fact]
public override async Task CanFillQueueWithNewestFirstAfterCancelingQueuedRequestWithAnotherQueuedRequest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,29 @@ public void ReplenishingRateLimiterPropertiesHaveCorrectValues()
Assert.Equal(replenishPeriod, limiter2.ReplenishmentPeriod);
}

[Fact]
public void SubMillisecondReplenishmentPeriodWithAutoReplenishmentDoesNotDisableTimer()
{
// Regression test for https://github.com/dotnet/runtime/issues/109027
// System.Threading.Timer truncates its period to whole milliseconds, so a sub-millisecond
// ReplenishmentPeriod previously produced a timer period of 0, which fires once and never again,
// silently stopping auto-replenishment. The timer period is now clamped to a 1ms floor.
var subMillisecond = TimeSpan.FromTicks(TimeSpan.TicksPerMillisecond / 2); // 500 microseconds
using ReplenishingRateLimiter limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
TokenLimit = 10,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
ReplenishmentPeriod = subMillisecond,
TokensPerPeriod = 1,
AutoReplenishment = true
});

// The clamp affects only the internal timer; the observable ReplenishmentPeriod still reflects the configured value.
Assert.True(limiter.IsAutoReplenishing);
Assert.Equal(subMillisecond, limiter.ReplenishmentPeriod);
}

[Fact]
public override void GetStatisticsReturnsNewInstances()
{
Expand Down
Loading