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()
{