diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiter.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiter.cs index ce241a6a342a07..270cb870e0aac8 100644 --- a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiter.cs +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiter.cs @@ -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); + /// public override TimeSpan? IdleDuration => RateLimiterHelper.GetElapsedTime(_idleSince); @@ -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); } } diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiterOptions.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiterOptions.cs index 8f7dbaa344beba..5c60be3afd9570 100644 --- a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiterOptions.cs +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiterOptions.cs @@ -11,6 +11,7 @@ public sealed class FixedWindowRateLimiterOptions /// /// Specifies the time window that takes in the requests. /// Must be set to a value greater than by the time these options are passed to the constructor of . + /// When is and this value is less than 1 millisecond, a period of 1 millisecond is used for the internal replenishment timer instead, as does not support sub-millisecond periods. /// public TimeSpan Window { get; set; } = TimeSpan.Zero; diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiter.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiter.cs index 29b16440cd8e03..1a09c8c4085010 100644 --- a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiter.cs +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiter.cs @@ -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); + /// public override TimeSpan? IdleDuration => RateLimiterHelper.GetElapsedTime(_idleSince); @@ -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); } } diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiterOptions.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiterOptions.cs index 93f7ba933b464f..0507886f1ab5f4 100644 --- a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiterOptions.cs +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiterOptions.cs @@ -11,6 +11,7 @@ public sealed class SlidingWindowRateLimiterOptions /// /// Specifies the minimum period between replenishments. /// Must be set to a value greater than by the time these options are passed to the constructor of . + /// The internal replenishment timer fires every divided by . When is and that derived period is less than 1 millisecond, a period of 1 millisecond is used instead, as does not support sub-millisecond periods. /// public TimeSpan Window { get; set; } = TimeSpan.Zero; diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiter.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiter.cs index 5c73c3671eaeff..8666c6d24b53f8 100644 --- a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiter.cs +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiter.cs @@ -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); + /// public override TimeSpan? IdleDuration => RateLimiterHelper.GetElapsedTime(_idleSince); @@ -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); } } diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiterOptions.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiterOptions.cs index 2c065d9432e67c..0962a797dfdb80 100644 --- a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiterOptions.cs +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/TokenBucketRateLimiterOptions.cs @@ -11,6 +11,7 @@ public sealed class TokenBucketRateLimiterOptions /// /// Specifies the minimum period between replenishments. /// Must be set to a value greater than by the time these options are passed to the constructor of . + /// When is and this value is less than 1 millisecond, a period of 1 millisecond is used for the internal replenishment timer instead, as does not support sub-millisecond periods. /// public TimeSpan ReplenishmentPeriod { get; set; } = TimeSpan.Zero; diff --git a/src/libraries/System.Threading.RateLimiting/tests/FixedWindowRateLimiterTests.cs b/src/libraries/System.Threading.RateLimiting/tests/FixedWindowRateLimiterTests.cs index 6b29041fc9b7de..29cbccc76d9234 100644 --- a/src/libraries/System.Threading.RateLimiting/tests/FixedWindowRateLimiterTests.cs +++ b/src/libraries/System.Threading.RateLimiting/tests/FixedWindowRateLimiterTests.cs @@ -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() { diff --git a/src/libraries/System.Threading.RateLimiting/tests/SlidingWindowRateLimiterTests.cs b/src/libraries/System.Threading.RateLimiting/tests/SlidingWindowRateLimiterTests.cs index d2e92bd28fb23b..0da105429eaa21 100644 --- a/src/libraries/System.Threading.RateLimiting/tests/SlidingWindowRateLimiterTests.cs +++ b/src/libraries/System.Threading.RateLimiting/tests/SlidingWindowRateLimiterTests.cs @@ -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() { diff --git a/src/libraries/System.Threading.RateLimiting/tests/TokenBucketRateLimiterTests.cs b/src/libraries/System.Threading.RateLimiting/tests/TokenBucketRateLimiterTests.cs index fb8436021c6749..c52f3e64f8d0e8 100644 --- a/src/libraries/System.Threading.RateLimiting/tests/TokenBucketRateLimiterTests.cs +++ b/src/libraries/System.Threading.RateLimiting/tests/TokenBucketRateLimiterTests.cs @@ -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() {