fix(client): coordinate rate-limit backoff across concurrent requests - #272
Merged
Merged
Conversation
Each request backed off on its own, so a burst of concurrent requests (page tree traversal runs ten at a time) that hit a low shared limit, such as 3 requests per second on Data Center, was rejected together, retried in lockstep, and exhausted its retries even though a patient client would have finished. #258 fixed immediate retries on Retry-After: 0 but not this. Add a RequestGate shared by every request of a client: - a throttled response pauses all requests, not only the retrying one; - a new throttle doubles the minimum spacing between request starts and successes halve it back, since capping concurrency alone does not help when responses arrive faster than the limit; - rejections from a burst that was already in flight are stale and neither widen the spacing again nor use up the request's own retries, with a cap on how many it may ride out. Without throttling nothing is paced or delayed. Retry counts, delays for a single request, and the error surfaced after the retries run out are unchanged. Refs #261
…ries Review of the coordinated backoff found two regressions against main: - A server that kept refusing made concurrent requests fail only after tens of minutes (7 s on main with ten in flight), because the shared pause and spacing grew with every retry of every request. - Throttling that does not depend on the request rate (a flaky proxy) ratcheted the spacing up and left the client slow for good. Changes: - The gate only handles 429; a 503 keeps its independent per-request retries exactly as before. - Only a positive Retry-After pauses all requests. Without one (Data Center sends 0) just the spacing between request starts changes. - The backoff exponent never exceeds what one request would reach alone. - A circuit opens after a run of new 429s with no success (maxRetries + 1 with a Retry-After, maxRetries + 4 without, since the spacing needs a few doublings to find the server's pace) and nothing more is retried or delayed until a request succeeds, so a persistent refusal fails about as fast as before. - A rejection counts against a request's own retries only when no request has succeeded since it first started; otherwise one unlucky request could run out of retries while the server serves everyone else. Such free retries are capped at six. - The spacing decays by a fixed factor per success, with no memory of the rate that caused a throttle, so isolated 429s leave no trace. Refs #261
- Cap the spacing between request starts at 2 s (was 5 s) so recovery from a run of rate-independent 429s no longer takes minutes, and a persistent refusal fails closer to main's 7 s. - Do not keep a Retry-After pause requested by the 429 that opens the circuit, and clear it when a success closes the circuit; it used to come back and stall an unrelated later request. - Allow as many free (non-counting) retries as the request has of its own, so a page that is always refused fails after the same four attempts as before while the rest of the traversal is served. - Add regression tests for both.
|
🎉 This PR is included in version 2.27.4 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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.
Problem
#258 fixed retries that fired immediately when
Retry-Afteris zero. It does not fix rate limiting during recursive page traversal:getAllDescendantPagesruns up to 10 requests concurrently while each request retries on its own (3 retries, 1s/2s/4s). A burst that hits a low shared limit (Data Center can allow as little as 3 requests per second) is rejected together, retried in lockstep, rejected again, and runs out of retries even though a patient client would have finished.Reproduction
tests/traversal-rate-limit.test.jsadds a deterministic stand-in for such a server: a token bucket on jest's fake timers (virtual time, so it is exact and takes milliseconds). Onmain, a 40-page tree against a 3 requests/second bucket fails withRequest failed with status code 429.What did not work
mainin two ways that review found: a persistent refusal made ten concurrent requests fail after 27 minutes instead of 7 seconds, and 429s unrelated to the request rate ratcheted the spacing up for good.Changes
New
lib/request-gate.js, shared by every request one client makes (a request interceptor acquires a start, the response interceptor reports successes):503keeps its independent per-request retries exactly as before and never paces other requests.Retry-Afterpauses every request; without one (Data Center answers0) there is nothing to wait for and just the spacing changes. The retrying request still waits its own delay, which for a single request is identical to before (the exponent never exceeds what one request would reach alone, so[1000, 2000]and[3000]are unchanged).maxRetries, so a page that is always refused fails after the same four attempts as before.maxRetries + 1with aRetry-After,maxRetries + 4without, since the spacing needs a few doublings to find the server's pace) the gate stops retrying and stops delaying until a request succeeds, so a server that keeps refusing fails about as fast as a single request would.Unchanged: the number of retries per request, the delay for a single request, which statuses and methods are retried, and the error surfaced when the retries run out. The gate waits with its own timer, so a caller that stubs
ConfluenceClient.sleepto skip the per-request backoff is not affected.Known limits
Retry-Afteris now honored by the whole client, as RFC 9110 describes, rather than only by the request that received it. If a server sends frequent shortRetry-Afters unrelated to load, every one of them pauses all requests: with 5% such responses a 500-page traversal takes about 46 s against about 4 s onmainat zero latency, and roughly 1.5-2x at 100-300 ms.mainat zero latency (117 s against 24 s with 100 ms latency); at 5%, 44 s against 7 s (28 s against 18 s). At 20%mainfails. A503flaky proxy is unaffected.mainfails on those too.Testing
Gate unit tests: no waiting without throttling, shared pause only with a
Retry-After, new vs stale, doubling and the cap, spacing between starts,expire, recovery and no ratchet from isolated throttles, the circuit (opens; stale rejections and successes keep it closed; bypasses waits while open; a pause requested by the opening throttle does not come back), and asleepthat returns early cannot loop.Traversal tests on the virtual-time server: the 40-page tree at 3 requests/second; a wider tree under 1 and 2 requests/second, a large burst with a low steady rate, a positive
Retry-After, and none; no limit (nothing paced or delayed); a server that never lets a request through; fifty concurrent requests against a server that always refuses must fail within 20 s (withRetry-After: 60: within 4 minutes); one page that is always refused fails after the same four attempts while the rest is served; 503s are neither gated nor paced; isolated 429s do not leave the client throttled; concurrent requests share one pause.A sweep against
mainwith a virtual clock and a custom axios adapter, 10 concurrent requests (the harness is in the review thread of this PR):mainRetry-After: 60Retry-After: 30npm test(42 suites, 1766 tests) andeslintpass; the existing 20 rate-limit retry tests pass unmodified.Not verified
The Data Center 9.2.9 numbers in #258 were not reproduced against a live server; everything here is checked against simulated servers. A real server's latency and bucket shape may differ, so this is
Refs, notFixes.README gets one paragraph about the behavior next to the existing note on rate limits.
Refs #261