solana/rpc: wait as long as a rate-limited endpoint asks - #4174
Open
bgm-malbeclabs wants to merge 1 commit into
Open
solana/rpc: wait as long as a rate-limited endpoint asks#4174bgm-malbeclabs wants to merge 1 commit into
bgm-malbeclabs wants to merge 1 commit into
Conversation
The retry budget cannot rescue a rate-limited call, because it is shorter than the window that refused it. Backoff totals ~3s of jitter across four attempts (measured: [1.5s, 3.0s]); the provider fronting mainnet-beta enforces its limits over a rolling 10s window. So every attempt lands inside the window the first one was refused in, and four requests are spent to be told the same thing, aimed at an endpoint that is already shedding load. The number that fixes this is on the response, in Retry-After, and until now nothing here could reach it: solana-go discards the http.Response inside CallForInto and its error types carry a code and nothing else. So the transport records the header against the in-flight call, via an unexported context slot, and the retry loop takes it before waiting. The endpoint's own number then wins over our guess in both directions — a fixed 10s wait would stall a call the endpoint would have served again in 1s, and would still be short for a provider whose window is longer. The header is recorded on every response, not only on a 429. The refusals that motivated this arrived as HTTP 200 with the rate limit inside the JSON-RPC envelope, a shape no status-code check matches. It costs nothing: the retry loop reads the value only after an attempt failed retryably. Waits are spread upward only. Every client refused in the same window otherwise resumes together and re-spikes the endpoint as the window rolls, and arriving early is arriving refused, so the spread never subtracts. One call holds for at most 15s of endpoint-requested waiting, summed across its attempts. That clears the 10s window it is meant to outlast, and keeps the worst case at 4 attempts of 10s plus 15s, so 55s, still inside state-ingest's 60s tick — the caller that passes its root context straight down with no bound of its own. A call asked for longer stops instead of waiting a shorter time: a partial wait spends the wait and is refused anyway. It returns the rate limit itself, so the caller sees the cause rather than a deadline, and increments doublezero_solana_rpc_retry_after_exceeded_total, which is the series that says the allowance is sized wrong. Endpoints that send no Retry-After keep the existing jittered backoff untouched. Callers can tune or disable this with RetryOptions.MaxRetryAfter. defaultRequestTimeout's comment documented a ~43s worst case sized against that 60s tick, and the test asserting it hardcoded the 3s backoff total. Both now carry the allowance instead, because it is the term that bounds the wait. Verified end to end through the real constructor rather than the retry helper, since the value has to cross the transport boundary the helper cannot see: removing the one wiring line drops the observed wait from 1.1s to 2.8ms and both shapes of refusal fail.
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.
Stacked on #4161, which adds the transport wrapper this needs. Review that one first; the base here is
solana/observe-ratelimit-headers.Summary
Retry-Afteris carried from the transport to the retry loop, which cannot otherwise see it: solana-go discards thehttp.Responseand its error types keep only a code.doublezero_solana_rpc_retry_after_exceeded_totalsays when that allowance is sized wrong.Retry-Afterkeep the existing jittered backoff untouched.Why
The retry budget is shorter than the window that refuses it, so it cannot rescue a rate-limited call. Measured against the package defaults:
The provider fronting mainnet-beta enforces its limits over a rolling 10s window. Even the unluckiest 3s total lands inside the window the first attempt was refused in. Four requests are spent to be told the same thing, aimed at an endpoint that is already shedding load. A lake indexer activity sat in this state for 4.5 hours.
Guessing a longer number is worse in both directions: a fixed 10s wait stalls a call the endpoint would have served again in 1s, and it is still too short for a provider whose window is longer. Only the endpoint knows its window, and it says so in
Retry-After— which the provider confirms it sets on every refusal.How
solana-godiscards thehttp.ResponseinsideCallForInto, and*HTTPErroris{Code int, err error}while*RPCErroris{Code, Message, Data}. The header is gone before any error reaches the retry loop. So the transport writes it into an unexported per-call slot on the context, and the retry loop takes it before waiting.The header is recorded on every response, not only on a 429. The sustained refusals that motivated this arrived as HTTP 200 with the rate limit inside the JSON-RPC envelope, a shape no status-code check matches. It costs nothing, because the retry loop reads the value only after an attempt has failed retryably.
Waits are spread upward only. Every client refused in the same window otherwise resumes together and re-spikes the endpoint as the window rolls — the same reason the existing backoff is jittered. Arriving early is arriving refused, so the spread never subtracts.
The bound
The allowance is 15s per call, summed across attempts. Two things fix that number:
defaultRequestTimeoutdocuments the constraint as state-ingest's 60s tick, because state-ingest passes its root context straight toGetProgramDatawith no call-site bound. 4 attempts of 10s plus 15s is 55s, still inside it.A call asked for longer than the allowance stops rather than waiting a shorter time: a partial wait spends the wait and is refused anyway, since the window has not rolled. It returns the rate-limit error itself, so the caller sees the cause instead of a deadline, and the caller's own next poll arrives after the window regardless.
That comment and the test asserting it both hardcoded the old 3s backoff total against the 60s tick. Both now carry the allowance instead, because it is the term that bounds the wait.
Testing Verification
rpc.New: HTTP 429 status, and HTTP 200 with{"error":{"code":429}}in the envelope. Both wait the header and then succeed.Retry-After: 600ends the call after one attempt, returns an error naming 429, and does not hold the caller while deciding.ParseRetryAfterover delta-seconds, an HTTP-date ahead, an HTTP-date already past, zero, negative, garbage, and absent.NoteRetryAfteron a context with no slot is a no-op, since the transport cannot know which client it serves.takeclears the slot, so a header from one attempt cannot pace the next.GOOS=linux go build ./tools/... ./controlplane/...clean;go test -race ./tools/solana/...green.