test: stop rate-limit tests straddling a fixed-window boundary - #170
Merged
Conversation
`accepts Keycloak azp as the client identifier` failed on main under Node 24 while passing under Node 22, which reads like a version problem and is not one. The limiter counts into a window keyed on Math.floor(now / 60_000), so the window is aligned to the wall clock rather than to a caller's first request. Two calls a millisecond apart land in different windows when they straddle a minute boundary, and the second starts a fresh count -- so an assertion that the next call is 429 sees 200 instead. The failing run reported the case at 14:47:00.037 after 45 ms, meaning it started at 14:46:59.99 and crossed the boundary mid-test. Node 24 was coincidence: that job happened to reach the case at the wrong moment. Two changes: - Pin the boundary behaviour deterministically in the rate-limit unit tests using the injectable clock the limiter already accepts, so this property is documented rather than rediscovered through a flake. It is the accepted trade-off of a fixed window, not a defect, and the retryAfterSeconds case shows the value points at the reset. - Guard the seven HTTP-boundary tests whose assertions need two or more calls to share a window. They now wait only when the current window has less than five seconds left, so the common path costs nothing. The product is unchanged. Adding a window-length knob purely so tests could avoid this would have put test convenience into the rate limiter. Honest scope note: the limiter-level reset is proven deterministically, and the CI timestamp matches it exactly, but I could not reproduce the HTTP-level flake on demand -- it needs the boundary to fall between two specific calls, a millisecond-wide target. Running the suite across a boundary passes either way, because the affected cases sit late enough in the file to be past it. Unit 1,941 pass, lint clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The failure
accepts Keycloak azp as the client identifierfailed onmainunder Node 24 while passing under Node 22 — which reads like a version problem and is not one.Why it happened
The limiter counts into a window keyed on
Math.floor(now / 60_000)(src/mcp-rate-limit.js:35), so the window is aligned to the wall clock, not to a caller's first request. Two calls a millisecond apart land in different windows when they straddle a minute boundary, and the second starts a fresh count — so "the next call is 429" sees 200.The failing run reported the case at 14:47:00.037 after 45 ms, meaning it started at 14:46:59.99 and crossed the boundary mid-test. Node 24 was coincidence: that job happened to reach the case at the wrong moment.
Changes
1. Pin the boundary behaviour deterministically in the rate-limit unit tests, using the injectable clock the limiter already accepts. This is the accepted trade-off of a fixed window, not a defect — but it should be documented rather than rediscovered through a flake. A
retryAfterSecondscase also shows the value points at the reset rather than a full window from now.2. Guard the seven HTTP-boundary tests whose assertions need two or more calls to share a window. They wait only when the current window has less than five seconds left, so the common path costs nothing.
The product is unchanged
Adding a window-length knob purely so tests could sidestep this would have put test convenience into the rate limiter.
Scope note
The limiter-level reset is proven deterministically and the CI timestamp matches it exactly, but I could not reproduce the HTTP-level flake on demand — it needs the boundary to fall between two specific calls, a millisecond-wide target. Running the whole suite across a boundary passes either way, because the affected cases sit late enough in the file to already be past it.
🤖 Generated with Claude Code