Stop the rate-limiting tests racing their own window #203

Merged
rob merged 2 commits from rate-limit-test-windows into main 2026-08-20 19:33:02 +00:00
Owner

Task 267. Five test classes gave themselves a two-second rate-limit window and then did work of uncontrolled duration inside it — a password hash per attempt, stub-provider discovery, a container round trip — so the window could roll before the last request, which was then permitted instead of refused.

Enforcement tests now configure a five-minute window. They never wait for it, so the width costs nothing.

PostLogin_AfterTheWindowElapses_TheSameAddressIsPermittedAgain and PostInvite_AfterTheWindowElapses_TheSameCallerIsPermittedAgain keep two seconds, through a factory of their own — they are the two that need the window to roll. The login one now spends its permits on requests the validation filter refuses with a 400 before the handler hashes anything, so nothing slow happens inside those two seconds; the invitation one warms the host on another caller's budget first. No sleep was lengthened and nothing retries.

Beyond the two classes named in the ticket, AuthenticationRateLimiterBehindATrustedProxyTests, InvitationRateLimiterTests and InvitationIpRateLimiterTests had the same shape and are fixed the same way.

Also tightened both Retry-After assertions, which a wide window would otherwise have made vacuous: the header must now be what is left of this window, not merely something positive.

Proof, all dotnet test PlaceMark.slnx with 120 CPU hogs running alongside:

  • Before: 5 failures across 3 runs, every one exhaustingResponse.StatusCode should be TooManyRequests but was OK.
  • After: 6 clean runs of the rate-limiting classes, then 3 clean full-solution runs (2174 tests).

Mutations watched go red: auth paths never matched by the limiter (all twelve tests asserting a 429 on an auth path — six in AuthenticationRateLimiterTests, five in AuthenticationRateLimiterBehindATrustedProxyTests, one in OidcRateLimitingTests — red); the configured auth window discarded for the default (only the rolling test and the Retry-After assertion red); RequireInvitationRateLimit() deleted from the route (all 5 invitation tests red); the configured invitation window discarded (only the rolling test red).

Task 267. Five test classes gave themselves a two-second rate-limit window and then did work of uncontrolled duration inside it — a password hash per attempt, stub-provider discovery, a container round trip — so the window could roll before the last request, which was then permitted instead of refused. Enforcement tests now configure a five-minute window. They never wait for it, so the width costs nothing. `PostLogin_AfterTheWindowElapses_TheSameAddressIsPermittedAgain` and `PostInvite_AfterTheWindowElapses_TheSameCallerIsPermittedAgain` keep two seconds, through a factory of their own — they are the two that need the window to roll. The login one now spends its permits on requests the validation filter refuses with a 400 before the handler hashes anything, so nothing slow happens inside those two seconds; the invitation one warms the host on another caller's budget first. No sleep was lengthened and nothing retries. Beyond the two classes named in the ticket, `AuthenticationRateLimiterBehindATrustedProxyTests`, `InvitationRateLimiterTests` and `InvitationIpRateLimiterTests` had the same shape and are fixed the same way. Also tightened both `Retry-After` assertions, which a wide window would otherwise have made vacuous: the header must now be what is left of *this* window, not merely something positive. Proof, all `dotnet test PlaceMark.slnx` with 120 CPU hogs running alongside: - Before: 5 failures across 3 runs, every one `exhaustingResponse.StatusCode should be TooManyRequests but was OK`. - After: 6 clean runs of the rate-limiting classes, then 3 clean full-solution runs (2174 tests). Mutations watched go red: auth paths never matched by the limiter (all twelve tests asserting a 429 on an auth path — six in `AuthenticationRateLimiterTests`, five in `AuthenticationRateLimiterBehindATrustedProxyTests`, one in `OidcRateLimitingTests` — red); the configured auth window discarded for the default (only the rolling test and the `Retry-After` assertion red); `RequireInvitationRateLimit()` deleted from the route (all 5 invitation tests red); the configured invitation window discarded (only the rolling test red).
Stop the rate-limiting tests racing their own window
All checks were successful
CI / build (pull_request) Successful in 3m59s
CI / container-images (pull_request) Successful in 13s
CI / e2e (pull_request) Successful in 7m26s
77baa1b65b
rob left a comment

Verdict: changes needed

Two things, both small.

The 11 auth tests red in the PR body does not add up: twelve tests assert a 429 on an auth path — six in AuthenticationRateLimiterTests, five in AuthenticationRateLimiterBehindATrustedProxyTests, one in OidcRateLimitingTests — and a limiter that never matches the auth prefix reddens every one of them. Either name the one that stayed green, or say the mutation run was filtered; if OidcRateLimitingTests was outside the filter, that is the test whose entire subject is the path matching that mutation breaks, so it is the one worth re-running.

Plus the stale class remark noted inline.

The rest holds up. SpendAPermitAsync is safe on ordering, not incidentally: the auth limiter is RateLimiterOptions.GlobalLimiter on app.UseRateLimiter(), which sits above endpoint execution in Program.cs, so the permit is always taken before any endpoint filter runs — [Required] on both LoginRequest fields makes the 400 deterministic, and nothing hashes. The tightened Retry-After range is the specification's, not the observed value's: RateLimitRejectionResponse writes the lease's own MetadataName.RetryAfter, which FixedWindowRateLimiter supplies as the time remaining in the current window, so [window − 1min, window] is exactly "what is left of this window" with a minute of slack for the requests that spent the permits. Both rolling tests still fail if the window does not roll — each ends on a permitted response through the same partition it exhausted — and the invitation one cannot borrow the login one's trick, because RequireInvitationRateLimit is a route filter and RequestValidationFilter a group filter, so a refused body would never reach the limiter at all; the warming request is the right answer there. No src/ change and coverage-baseline.json untouched.

CI: run #837 for 77baa1b is still running (started 13:59 UTC, seven minutes ago). Not seen green.

Verdict: changes needed Two things, both small. The `11 auth tests red` in the PR body does not add up: twelve tests assert a 429 on an auth path — six in `AuthenticationRateLimiterTests`, five in `AuthenticationRateLimiterBehindATrustedProxyTests`, one in `OidcRateLimitingTests` — and a limiter that never matches the auth prefix reddens every one of them. Either name the one that stayed green, or say the mutation run was filtered; if `OidcRateLimitingTests` was outside the filter, that is the test whose entire subject is the path matching that mutation breaks, so it is the one worth re-running. Plus the stale class remark noted inline. The rest holds up. `SpendAPermitAsync` is safe on ordering, not incidentally: the auth limiter is `RateLimiterOptions.GlobalLimiter` on `app.UseRateLimiter()`, which sits above endpoint execution in `Program.cs`, so the permit is always taken before any endpoint filter runs — `[Required]` on both `LoginRequest` fields makes the 400 deterministic, and nothing hashes. The tightened `Retry-After` range is the specification's, not the observed value's: `RateLimitRejectionResponse` writes the lease's own `MetadataName.RetryAfter`, which `FixedWindowRateLimiter` supplies as the time remaining in the current window, so `[window − 1min, window]` is exactly "what is left of this window" with a minute of slack for the requests that spent the permits. Both rolling tests still fail if the window does not roll — each ends on a permitted response through the same partition it exhausted — and the invitation one cannot borrow the login one's trick, because `RequireInvitationRateLimit` is a route filter and `RequestValidationFilter` a group filter, so a refused body would never reach the limiter at all; the warming request is the right answer there. No `src/` change and `coverage-baseline.json` untouched. CI: run #837 for `77baa1b` is still running (started 13:59 UTC, seven minutes ago). Not seen green.
@ -40,0 +43,4 @@
/// the width costs nothing; at two seconds the window could roll between the second invitation and
/// the third under load, and the request that should have been refused was permitted instead.
/// </summary>
private static readonly TimeSpan _enforcementWindow = TimeSpan.FromMinutes(5);
Author
Owner

The class remarks above (line 27) still say "two invitations inside two seconds" — false now, and the identical sentence in AuthenticationRateLimiterTests was corrected in this PR.

The class remarks above (line 27) still say "two invitations inside two seconds" — false now, and the identical sentence in `AuthenticationRateLimiterTests` was corrected in this PR.
Drop the stale window from the invitation limiter test remarks
Some checks failed
CI / build (pull_request) Successful in 9m57s
CI / container-images (pull_request) Successful in 1m19s
CI / e2e (pull_request) Failing after 22m25s
1d798768f4
rob force-pushed rate-limit-test-windows from 1d798768f4
Some checks failed
CI / build (pull_request) Successful in 9m57s
CI / container-images (pull_request) Successful in 1m19s
CI / e2e (pull_request) Failing after 22m25s
to 42c0f6e13a
All checks were successful
CI / build (pull_request) Successful in 5m39s
CI / container-images (pull_request) Successful in 4s
CI / e2e (pull_request) Successful in 5m6s
2026-08-20 14:58:31 +00:00
Compare
Author
Owner

CI: #837 (77baa1b, the fix itself) was fully green. #840 (1d79876, that plus a doc comment) failed e2e only — build green — with Playwright 30s navigation timeouts; unrelated PR run #841 failed the same jobs the same way in the same window, on a runner carrying three runs at once (32m vs the usual ~8m). Re-triggered as #844 on 42c0f6e; still running, contending with #845.

CI: #837 (77baa1b, the fix itself) was fully green. #840 (1d79876, that plus a doc comment) failed `e2e` only — `build` green — with Playwright 30s navigation timeouts; unrelated PR run #841 failed the same jobs the same way in the same window, on a runner carrying three runs at once (32m vs the usual ~8m). Re-triggered as #844 on 42c0f6e; still running, contending with #845.
Author
Owner

#844 on 42c0f6e: build success, container-images success, e2e still running after ~2h — it is sharing the runner with #845's e2e, which is the same contention that produced #840's and #841's Playwright timeouts. Re-check before merging; every test this PR touches runs in build.

#844 on 42c0f6e: `build` success, `container-images` success, `e2e` still running after ~2h — it is sharing the runner with #845's `e2e`, which is the same contention that produced #840's and #841's Playwright timeouts. Re-check before merging; every test this PR touches runs in `build`.
rob left a comment

Verdict: mergeable

Re-review at 42c0f6e, superseding review 329 (77baa1b). Nothing to action.

The count is right at twelve, and complete. Six [Fact]s in AuthenticationRateLimiterTests, five in AuthenticationRateLimiterBehindATrustedProxyTests, one in OidcRateLimitingTests, and every one of them asserts a 429 on an auth path. There is no fourth class: a test that asserts a 429 there has to lower RateLimiting:Auth:PermitLimit from the shipped ten a minute, and only those three do. The rest of the folder is the two options classes, which parse configuration with no server, and AuthenticationPathMatchingTests, which asserts IsAuthenticationRequest against routing and never a status code — worth knowing that if the mutation was cut at IsAuthenticationRequest, that theory reddens too, so the blast radius is wider than twelve rather than narrower.

The remarks now read accurately: ADR-0113's default is DefaultPermitLimit = 10 over _defaultWindow = TimeSpan.FromMinutes(5), and "configured per factory" is exactly what CreateFactory(window) does with RateLimiting:Invitations:PermitLimit.

Nothing drifted. The only change since 77baa1b is those two lines of prose; the five-minute enforcement windows, both rolling factories at two seconds, the warming invitation and the Retry-After bounds are byte-identical to what I cleared last round.

CI: run #844 for 42c0f6e is success, 10m47s.

Verdict: mergeable Re-review at `42c0f6e`, superseding review 329 (`77baa1b`). Nothing to action. The count is right at twelve, and complete. Six `[Fact]`s in `AuthenticationRateLimiterTests`, five in `AuthenticationRateLimiterBehindATrustedProxyTests`, one in `OidcRateLimitingTests`, and every one of them asserts a 429 on an auth path. There is no fourth class: a test that asserts a 429 there has to lower `RateLimiting:Auth:PermitLimit` from the shipped ten a minute, and only those three do. The rest of the folder is the two options classes, which parse configuration with no server, and `AuthenticationPathMatchingTests`, which asserts `IsAuthenticationRequest` against routing and never a status code — worth knowing that if the mutation was cut at `IsAuthenticationRequest`, that theory reddens too, so the blast radius is wider than twelve rather than narrower. The remarks now read accurately: ADR-0113's default is `DefaultPermitLimit = 10` over `_defaultWindow = TimeSpan.FromMinutes(5)`, and "configured per factory" is exactly what `CreateFactory(window)` does with `RateLimiting:Invitations:PermitLimit`. Nothing drifted. The only change since `77baa1b` is those two lines of prose; the five-minute enforcement windows, both rolling factories at two seconds, the warming invitation and the `Retry-After` bounds are byte-identical to what I cleared last round. CI: run #844 for `42c0f6e` is `success`, 10m47s.
rob merged commit 04ef274bba into main 2026-08-20 19:33:02 +00:00
rob deleted branch rate-limit-test-windows 2026-08-20 19:33:02 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!203
No description provided.