Skip to content

Retry rate-limited RPC requests in the CLI - #112

Merged
lorisleiva merged 3 commits into
mainfrom
fix/cli-retry-rate-limited-rpc
Sep 4, 2026
Merged

Retry rate-limited RPC requests in the CLI#112
lorisleiva merged 3 commits into
mainfrom
fix/cli-retry-rate-limited-rpc

Conversation

@lorisleiva

Copy link
Copy Markdown
Member

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 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.

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.
@lorisleiva

Copy link
Copy Markdown
Member Author

@trevor-cortex

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-After is 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 AbortSignal through request.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 onRetry hook (or a logWarning in 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-rpc source 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 the planAndSendTransactions() helpers from @solana/kit-plugin-instruction-plan (client.sendTransaction(...) etc.). The CLI's cliRunOrExport only uses planTransactions + sendTransactions(plan), and programMetadataProgram() only requires planning/sending interfaces, so this should be fine — and TS would catch any missing member — but worth a quick tsc sanity check that nothing in cli/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-After value falling back to jittered backoff (the return null branch in getRetryAfterMs).
  • No .changeset/ in this repo, so nothing to add there.

Comment thread clients/js/src/cli/rpc.ts
Comment thread clients/js/src/cli/rpc.ts Outdated
Comment thread clients/js/src/cli/utils.ts
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 trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review

All the points from my first pass have been addressed:

  • Retry-After now 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. getRetryDecision returning a discriminated retry/give-up result is a nice clean way to express it.
  • defaultSleep is abort-aware and the loop short-circuits on request.signal.aborted.
  • onRetry hook added and wired into getClient with a logWarning, so a rate-limited upload no longer looks hung.
  • Test coverage added for the unparseable Retry-After fallback, the ceiling give-up, onRetry and 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 the defaultSleep docstring describes.
  • getReadonlyClient gets the retrying RPC but no onRetry, 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 the onRetry callback would tame it without touching the transport.
  • My earlier note still stands: I haven't diffed against the solanaRpc bundle source, so a quick tsc/manual run confirming nothing in cli/commands/* relied on extra bundle-only helpers is worthwhile.

Comment thread clients/js/src/cli/rpc.ts
Comment thread clients/js/src/cli/utils.ts Outdated
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 trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (3)

Both nits from the previous pass are addressed:

  • The loop now re-checks request.signal?.aborted after the sleep and rethrows the original 429 instead of making one more transport call. The abort test's inner.calls === 1 assertion now genuinely exercises that path, and the code matches the defaultSleep docstring.
  • getRetryingRpcConfig() is shared between getClient and getReadonlyClient, 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-After ceiling) rides out a real limiter window, and that the per-request warnings aren't too chatty under a parallel plan. Both are tunable in getRetryingRpcConfig() / the constants at the top of rpc.ts without touching the transport logic.
  • No .changeset/ in this repo, so nothing to add there.

@lorisleiva
lorisleiva merged commit a8a4c74 into main Sep 4, 2026
21 checks passed
@lorisleiva
lorisleiva deleted the fix/cli-retry-rate-limited-rpc branch September 4, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants