Pin the budget sweep and Retry-After rounding; drop a redundant Math.Max

Survivors of the first Stryker pass on FixedWindowBudget: the sweep dropping
live windows, sweeping every call, and the exact window boundary (masked by
the sweep in the first test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 09:35:50 +02:00
commit 9fcef927fd
3 changed files with 45 additions and 3 deletions

View file

@ -92,14 +92,15 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLim
Assert.Equal("600", RetryAfter(first));
}
_factory.Clock.Advance(TimeSpan.FromSeconds(100));
// 499.5 s remain: Retry-After rounds up, so a client told to wait never retries early.
_factory.Clock.Advance(TimeSpan.FromMilliseconds(100_500));
using (var later = await device.Post(collection, "Up"))
{
Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode);
Assert.Equal("500", RetryAfter(later));
}
_factory.Clock.Advance(TimeSpan.FromSeconds(499));
_factory.Clock.Advance(TimeSpan.FromMilliseconds(498_500));
using (var last = await device.Post(collection, "Up"))
{
Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode);

View file

@ -54,6 +54,47 @@ public sealed class FixedWindowBudgetTests
Assert.True(budget.TryAcquire("a", out _));
}
[Fact]
public void A_sweep_drops_only_expired_windows_and_a_live_one_keeps_counting_to_its_exact_end()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, clock);
budget.TryAcquire("a", out _);
clock.Advance(TimeSpan.FromSeconds(30));
budget.TryAcquire("b", out _);
clock.Advance(TimeSpan.FromSeconds(30));
// The sweep due at 60 s drops a, which ended, and keeps b, which has 30 s left.
budget.TryAcquire("c", out _);
Assert.Equal(2, budget.TrackedKeys);
Assert.False(budget.TryAcquire("b", out var wait));
Assert.Equal(TimeSpan.FromSeconds(30), wait);
clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1));
Assert.False(budget.TryAcquire("b", out _));
clock.Advance(TimeSpan.FromTicks(1));
Assert.True(budget.TryAcquire("b", out _));
}
[Fact]
public void Sweeping_is_at_most_once_per_window_so_a_flood_of_keys_costs_one_pass_not_one_per_request()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, clock);
budget.TryAcquire("a", out _);
clock.Advance(TimeSpan.FromSeconds(30));
budget.TryAcquire("b", out _);
clock.Advance(TimeSpan.FromSeconds(31));
budget.TryAcquire("c", out _);
Assert.Equal(2, budget.TrackedKeys);
// b ended at 90 s but the next sweep is not due until 121 s.
clock.Advance(TimeSpan.FromSeconds(39));
budget.TryAcquire("d", out _);
Assert.Equal(3, budget.TrackedKeys);
}
[Fact]
public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen()
{