Retry rate-limited RPC requests in the CLI - #112
Conversation
This makes the CLI resilient to RPC rate limiting (HTTP 429), which currently aborts metadata uploads part way through. Uploading an IDL fans a large payload out into many write transactions, each performing several RPC calls; against a rate-limited endpoint (public devnet in particular) those bursts trip the limiter and the whole upload fails with "HTTP error (429): Too Many Requests". The kit RPC executor has no built-in retry, and neither solanaRpc nor createSolanaRpc exposes a custom-transport hook, so the retry is added at the transport layer. A new createRetryingSolanaRpc wraps the default transport to retry on HTTP 429, honouring the server's Retry-After header when present and otherwise falling back to exponential backoff with full jitter (capped at 10s). Only 429s are retried; every other error propagates immediately. Because the retry lives in the transport, it covers every RPC call (blockhash lookups, simulations, sends and status polls), not just sends. To inject the retrying transport, getClient now builds the RPC and subscriptions itself and applies solanaRpc's constituent plugins (rpcGetMinimumBalance, rpcTransactionPlanner, rpcTransactionPlanSendingExecutor) rather than the all-in-one solanaRpc, keeping executor and planner defaults identical. getReadonlyClient uses the retrying RPC too.
trevor-cortex
left a comment
There was a problem hiding this comment.
Summary
Adds a createRetryingSolanaRpc that wraps kit's default HTTP transport with a 429-only retry loop (honours Retry-After, otherwise full-jitter exponential backoff capped at 10s, 5 retries by default). getClient swaps the solanaRpc bundle for its constituent plugins (rpcGetMinimumBalance, rpcTransactionPlanner, rpcTransactionPlanSendingExecutor) so the retrying rpc can be injected via extendClient; getReadonlyClient uses the retrying RPC too. Unit tests cover the retry loop and delay computation against a mock transport.
The approach is right: transport-level retry is exactly where kit intends this to live, it covers every call the planner/executor makes, and restricting to 429 keeps genuine failures fast. The code is clean and well documented. Approving — the points below are non-blocking suggestions.
Things to watch
Retry-Afteris capped at the same 10s ceiling as the backoff. If the server asks for, say, 15s, we wait 10s, get another 429, and burn a retry. Worth considering a separate (higher) ceiling for server-provided values, or giving up early when the requested wait exceeds what we're willing to honour, so the budget isn't spent on retries that are guaranteed to fail. See inline.- Sleep isn't abort-aware. The planner/executor pass an
AbortSignalthroughrequest.signal; if it fires mid-backoff, the wrapper keeps sleeping (up to 10s) and only surfaces the abort on the next transport call. Minor for the CLI today, but cheap to fix. See inline. - No user-visible feedback while retrying. From the CLI user's perspective a rate-limited upload will now pause silently for potentially tens of seconds per burst. An optional
onRetryhook (or alogWarningin the CLI wiring) would make the difference between "it's working" and "it's hung" obvious. Optional. - Plugin parity with
solanaRpc. I couldn't pull the@solana/kit-plugin-rpcsource from here to diff against, so I'm taking the "identical defaults" claim on trust. One specific thing to double-check: the bundle presets also install theplanAndSendTransactions()helpers from@solana/kit-plugin-instruction-plan(client.sendTransaction(...)etc.). The CLI'scliRunOrExportonly usesplanTransactions+sendTransactions(plan), andprogramMetadataProgram()only requires planning/sending interfaces, so this should be fine — and TS would catch any missing member — but worth a quicktscsanity check that nothing incli/commands/*relied on the extra helpers.
Notes for subsequent reviewers
- The retry has no global throttle; each in-flight request retries independently. With a parallel transaction plan and a large IDL, a burst of N concurrent 429s becomes N jittered retries, which is the intended "spread out" behaviour, but it's worth a manual run against public devnet to confirm the default budget (5 retries, ~15s worst-case total sleep without
Retry-After) is actually enough to ride out a limiter window. - Tests are focused and readable. One small gap: there's no test for an unparseable
Retry-Aftervalue falling back to jittered backoff (thereturn nullbranch ingetRetryAfterMs). - No
.changeset/in this repo, so nothing to add there.
Refines the rate-limit retry wrapper following review. A server-provided Retry-After is now honoured up to a separate, more generous ceiling (60s); if the server asks for longer, the request gives up immediately rather than spending a retry on a wait that is unlikely to succeed. The exponential backoff path keeps its 10s cap. The backoff sleep is now abort-aware, so a request cancelled mid-wait (e.g. when the executor cancels siblings after a failure) surfaces promptly instead of hanging for up to the full delay. A new onRetry hook lets callers observe retries; the CLI uses it to log a "rate limited, retrying in Xs" warning so uploads no longer appear to hang. The retry decision is factored into a pure getRetryDecision helper, and tests cover the new ceiling, unparseable Retry-After, abort-mid-backoff and onRetry behaviours.
trevor-cortex
left a comment
There was a problem hiding this comment.
Re-review
All the points from my first pass have been addressed:
Retry-Afternow has its own 60s ceiling (MAX_RETRY_AFTER_MS) and the wrapper gives up immediately rather than burning a retry when the server asks for longer.getRetryDecisionreturning a discriminatedretry/give-upresult is a nice clean way to express it.defaultSleepis abort-aware and the loop short-circuits onrequest.signal.aborted.onRetryhook added and wired intogetClientwith alogWarning, so a rate-limited upload no longer looks hung.- Test coverage added for the unparseable
Retry-Afterfallback, the ceiling give-up,onRetryand the abort path.
Still approving. Two small non-blocking notes inline:
- After an abort fires mid-sleep, the loop makes one more
transport()call before it notices the signal. Harmless (fetch rejects synchronously on an already-aborted signal), but a post-sleep check would match what thedefaultSleepdocstring describes. getReadonlyClientgets the retrying RPC but noonRetry, so read commands (fetch,list, …) will retry silently while write commands warn. Probably worth the same warning for consistency.
Notes for subsequent reviewers
- With a parallel transaction plan, N concurrent 429s will each log their own
[Warning] RPC rate limited…line. That's probably acceptable signal for a CLI, but if it turns out to be noisy against public devnet a simple "only log once per burst" debounce in theonRetrycallback would tame it without touching the transport. - My earlier note still stands: I haven't diffed against the
solanaRpcbundle source, so a quicktsc/manual run confirming nothing incli/commands/*relied on extra bundle-only helpers is worthwhile.
Addresses two non-blocking review nits. The retry loop now re-checks the abort signal after the backoff sleep, so a request cancelled mid-wait surfaces the original 429 rather than looping into a transport call that would reject with an AbortError (matching the defaultSleep docstring). The onRetry warning is now shared between getClient and getReadonlyClient via a getRetryingRpcConfig helper, so read commands warn on rate limiting too instead of pausing silently.
trevor-cortex
left a comment
There was a problem hiding this comment.
Re-review (3)
Both nits from the previous pass are addressed:
- The loop now re-checks
request.signal?.abortedafter the sleep and rethrows the original 429 instead of making one more transport call. The abort test'sinner.calls === 1assertion now genuinely exercises that path, and the code matches thedefaultSleepdocstring. getRetryingRpcConfig()is shared betweengetClientandgetReadonlyClient, so read and write commands both surface the[Warning] RPC rate limited…message consistently.
I re-read rpc.ts end to end against the current diff and have nothing further to raise — the retry/give-up decision, header parsing, jittered backoff, abort handling and test coverage all look solid. Approving; good to merge from my side.
Notes for subsequent reviewers
- Unchanged from before: a manual run against public devnet (large IDL upload) is still the best validation that the default budget (5 retries, 60s
Retry-Afterceiling) rides out a real limiter window, and that the per-request warnings aren't too chatty under a parallel plan. Both are tunable ingetRetryingRpcConfig()/ the constants at the top ofrpc.tswithout touching the transport logic. - No
.changeset/in this repo, so nothing to add there.
This PR makes the CLI resilient to RPC rate limiting (HTTP 429), which currently aborts metadata uploads part way through. Uploading an IDL fans a large payload out into many write transactions, each performing several RPC calls; against a rate-limited endpoint (public devnet in particular) those bursts trip the limiter and the whole upload fails with
HTTP error (429): Too Many Requests.The kit RPC executor has no built-in retry, and neither
solanaRpcnorcreateSolanaRpcexposes a custom-transport hook, so the retry is added at the transport layer. A newcreateRetryingSolanaRpcwraps the default transport to retry on HTTP 429, honouring the server'sRetry-Afterheader when present and otherwise falling back to exponential backoff with full jitter (capped at 10s). Only 429s are retried; every other error propagates immediately. Because the retry lives in the transport, it covers every RPC call (blockhash lookups, simulations, sends and status polls), not just sends.To inject the retrying transport,
getClientnow builds the RPC and subscriptions itself and appliessolanaRpc's constituent plugins (rpcGetMinimumBalance,rpcTransactionPlanner,rpcTransactionPlanSendingExecutor) rather than the all-in-onesolanaRpc, keeping executor and planner defaults identical.getReadonlyClientuses the retrying RPC too.