Skip to content

Commit e89ea0b

Browse files
abuzuhriclaude
andcommitted
Serialize RateLimits.NextRate to stop token-bucket runaway (abuzuhri#940)
A single RateLimits instance is shared across all concurrent requests of a given RateLimitType. With no synchronization, parallel calls race on the token-bucket state: when the bucket is full, each thread advances LastRequest by ratePeriodMs, so the wait target keeps receding and the limiter never recovers until the process restarts (the reported "stuck until restart" symptom). The logic is correct single-threaded; the bug is purely concurrency. Gate NextRate behind a per-instance SemaphoreSlim (logic moved to NextRateCore). Each RateLimitType owns its own instance/gate, so distinct endpoints do not block each other. Also repair RateLimitsTests, which referenced the rolled-back WaitForPermittedRequest API and no longer compiled, and add a concurrency regression test that drives 10 parallel requests and asserts they drain within a bounded window. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3de6ce1 commit e89ea0b

2 files changed

Lines changed: 51 additions & 3 deletions

File tree

Source/FikaAmazonAPI/Utils/RateLimits.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Runtime.CompilerServices;
3+
using System.Threading;
34
using System.Threading.Tasks;
45

56
[assembly: InternalsVisibleTo("Tests")]
@@ -12,6 +13,15 @@ internal class RateLimits
1213
internal DateTime LastRequest { get; set; }
1314
internal int RequestsSent { get; set; }
1415

16+
/// <summary>
17+
/// A single shared <see cref="RateLimits"/> instance is reused for every concurrent request of the
18+
/// same <see cref="RateLimitType"/>. Without serialization, parallel calls race on <see cref="RequestsSent"/>
19+
/// and <see cref="LastRequest"/> — each thread that sees a full bucket advances <see cref="LastRequest"/>
20+
/// further into the future, so the wait target keeps receding and the limiter never recovers until the
21+
/// process is restarted. The gate forces one request through the token-bucket logic at a time.
22+
/// </summary>
23+
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
24+
1525
/// <summary>
1626
/// Constructor for rate limits configuration object
1727
/// </summary>
@@ -36,6 +46,22 @@ internal RateLimits(decimal Rate, int Burst)
3646
/// <param name="cancellationToken">The cancellation token</param>
3747
/// <returns></returns>
3848
public async Task<RateLimits> NextRate(RateLimitType rateLimitType)
49+
{
50+
// Serialize access so concurrent requests for the same rate limit type cannot corrupt the
51+
// shared token-bucket state (see _gate). Each rate limit type owns its own instance/gate, so
52+
// requests to different resources are unaffected.
53+
await _gate.WaitAsync();
54+
try
55+
{
56+
return await NextRateCore(rateLimitType);
57+
}
58+
finally
59+
{
60+
_gate.Release();
61+
}
62+
}
63+
64+
private async Task<RateLimits> NextRateCore(RateLimitType rateLimitType)
3965
{
4066
if (RequestsSent < 0)
4167
RequestsSent = 0;

Source/Tests/RateLimitsTests.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,15 @@ public class RateLimitsTests
1818
public async Task WaitForPermittedRequest_WaitsExpectedLengthOfTime(decimal rate, int burst, int numberRequests, int expectedWaitMilliseconds)
1919
{
2020
// Arrange
21-
var rateLimit = new RateLimits(rate, burst, RateLimitType.UNSET);
21+
var rateLimit = new RateLimits(rate, burst);
2222

2323
var stopwatch = new Stopwatch();
24-
var cancellationToken = new CancellationToken();
2524

2625
// Act
2726
stopwatch.Start();
2827
for (int i = 0; i < numberRequests; i++)
2928
{
30-
await rateLimit.WaitForPermittedRequest(cancellationToken, debugMode: true);
29+
await rateLimit.NextRate(RateLimitType.UNSET);
3130
}
3231
stopwatch.Stop();
3332

@@ -36,5 +35,28 @@ public async Task WaitForPermittedRequest_WaitsExpectedLengthOfTime(decimal rate
3635
// allow a second for additional time taken by the test to run
3736
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThanOrEqualTo(expectedWaitMilliseconds + 1000));
3837
}
38+
39+
// Regression test for issue #940: when many requests for the same rate limit type run in parallel
40+
// against the shared RateLimits instance, the token bucket must drain within the expected window.
41+
// Before NextRate was serialized, racing threads each pushed LastRequest further into the future,
42+
// so the limiter never recovered and effectively hung until the process was restarted.
43+
[Test]
44+
public async Task ConcurrentRequests_DrainWithinExpectedWindow()
45+
{
46+
// rate 1/s, burst 5 => 10 concurrent requests = 5 immediate + 5 spaced ~1s apart ~= 5s.
47+
var rateLimit = new RateLimits(1.0M, 5);
48+
49+
var stopwatch = Stopwatch.StartNew();
50+
51+
var tasks = Enumerable.Range(0, 10)
52+
.Select(_ => rateLimit.NextRate(RateLimitType.UNSET))
53+
.ToArray();
54+
await Task.WhenAll(tasks);
55+
56+
stopwatch.Stop();
57+
58+
Assert.That(stopwatch.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(4000));
59+
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThanOrEqualTo(8000));
60+
}
3961
}
4062
}

0 commit comments

Comments
 (0)