Skip to content

Commit a8a4c74

Browse files
authored
Retry rate-limited RPC requests in the CLI (#112)
* Retry rate-limited RPC requests in the CLI 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. * Address review feedback on RPC retries 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. * Apply review nits to RPC retries 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.
1 parent 89ca041 commit a8a4c74

3 files changed

Lines changed: 446 additions & 10 deletions

File tree

clients/js/src/cli/rpc.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import {
2+
createDefaultRpcTransport,
3+
createSolanaRpcFromTransport,
4+
isSolanaError,
5+
Rpc,
6+
RpcTransport,
7+
SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR,
8+
SolanaRpcApi,
9+
} from '@solana/kit';
10+
11+
/** The maximum number of retries attempted for a rate-limited request. */
12+
const DEFAULT_MAX_RETRIES = 5;
13+
/** The base delay, in milliseconds, used for exponential backoff. */
14+
const BASE_BACKOFF_MS = 500;
15+
/** The ceiling, in milliseconds, applied to computed exponential backoff delays. */
16+
const MAX_BACKOFF_MS = 10_000;
17+
/**
18+
* The ceiling, in milliseconds, applied to a server-provided `Retry-After`
19+
* value. When the server asks us to wait longer than this, we give up
20+
* immediately rather than spending a retry on a wait that is unlikely to be
21+
* worthwhile. This is deliberately more generous than {@link MAX_BACKOFF_MS}
22+
* because the server is telling us exactly when it will accept the request.
23+
*/
24+
const MAX_RETRY_AFTER_MS = 60_000;
25+
26+
/**
27+
* Information passed to the {@link RetryingRpcConfig.onRetry} callback before a
28+
* rate-limited request is retried.
29+
*/
30+
export type RetryInfo = {
31+
/** The zero-based index of the attempt that just failed. */
32+
attempt: number;
33+
/** The delay, in milliseconds, before the next attempt. */
34+
delayMs: number;
35+
/** The rate-limit error that triggered the retry. */
36+
error: unknown;
37+
};
38+
39+
/**
40+
* Options controlling how {@link createRetryingSolanaRpc} retries rate-limited
41+
* requests.
42+
*/
43+
export type RetryingRpcConfig = {
44+
/**
45+
* Optional configuration forwarded to the underlying default RPC transport,
46+
* such as custom headers.
47+
*/
48+
transportConfig?: Omit<Parameters<typeof createDefaultRpcTransport>[0], 'url'>;
49+
/**
50+
* The maximum number of retries attempted after an initial rate-limited
51+
* (HTTP 429) response before giving up. Defaults to {@link DEFAULT_MAX_RETRIES}.
52+
*/
53+
maxRetries?: number;
54+
/**
55+
* Called before each retry, after the delay has been computed but before it
56+
* elapses. Useful for surfacing progress (e.g. logging a "rate limited,
57+
* retrying in Xs" warning) so a paused request does not appear to hang.
58+
*/
59+
onRetry?: (info: RetryInfo) => void;
60+
/**
61+
* Sleep function used between retries. Injectable for testing; defaults to a
62+
* `setTimeout`-based delay that resolves early if the request is aborted.
63+
*/
64+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
65+
};
66+
67+
/**
68+
* Creates a Solana RPC client whose transport automatically retries requests
69+
* that fail with an HTTP 429 (Too Many Requests) response.
70+
*
71+
* Public RPC endpoints — devnet in particular — aggressively rate-limit bursts
72+
* of requests. Since uploading metadata fans a large payload out into many
73+
* write transactions (each performing several RPC calls), those bursts commonly
74+
* trip the rate limiter and abort the whole upload. Retrying at the transport
75+
* layer covers every RPC call (blockhash lookups, simulations, sends and status
76+
* polls), not just the sends.
77+
*
78+
* The retry honours the server's `Retry-After` header when present (giving up if
79+
* it asks for an unreasonably long wait); otherwise it falls back to exponential
80+
* backoff with jitter. Only HTTP 429 responses are retried — every other error
81+
* propagates immediately so genuine failures are surfaced without delay.
82+
*
83+
* @param url - The Solana RPC endpoint URL.
84+
* @param config - Optional retry and transport configuration.
85+
* @returns An {@link Rpc} backed by the retrying transport.
86+
*/
87+
export function createRetryingSolanaRpc(url: string, config: RetryingRpcConfig = {}): Rpc<SolanaRpcApi> {
88+
const baseTransport = createDefaultRpcTransport({ url, ...config.transportConfig });
89+
return createSolanaRpcFromTransport(withRateLimitRetries(baseTransport, config));
90+
}
91+
92+
/**
93+
* Wraps a transport so that requests failing with an HTTP 429 (Too Many
94+
* Requests) response are retried. Exposed separately from
95+
* {@link createRetryingSolanaRpc} so the retry behaviour can be tested against a
96+
* mock transport without performing real network I/O.
97+
*
98+
* @param transport - The underlying transport to wrap.
99+
* @param config - Optional retry configuration.
100+
* @returns A transport that retries rate-limited requests.
101+
*/
102+
export function withRateLimitRetries(transport: RpcTransport, config: RetryingRpcConfig = {}): RpcTransport {
103+
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
104+
const sleep = config.sleep ?? defaultSleep;
105+
106+
return async <TResponse>(request: Parameters<RpcTransport>[0]): Promise<TResponse> => {
107+
for (let attempt = 0; ; attempt++) {
108+
try {
109+
return await transport<TResponse>(request);
110+
} catch (error) {
111+
// Don't retry non-429 errors, once the budget is exhausted, or
112+
// once the request has been aborted (e.g. the executor
113+
// cancelled sibling requests after another transaction failed).
114+
const decision = getRetryDecision(error, attempt, maxRetries);
115+
if (decision.kind === 'give-up' || request.signal?.aborted) {
116+
throw error;
117+
}
118+
config.onRetry?.({ attempt, delayMs: decision.delayMs, error });
119+
await sleep(decision.delayMs, request.signal);
120+
// The sleep resolves early on abort; surface the original 429
121+
// rather than looping back into a transport call that would
122+
// reject with an `AbortError` instead.
123+
if (request.signal?.aborted) {
124+
throw error;
125+
}
126+
}
127+
}
128+
};
129+
}
130+
131+
/** The outcome of deciding whether and how long to wait before a retry. */
132+
type RetryDecision = { kind: 'retry'; delayMs: number } | { kind: 'give-up' };
133+
134+
/**
135+
* Decides whether a failed request should be retried and, if so, after how long.
136+
*
137+
* Retries only HTTP 429 (rate limit) errors, and only while retries remain. When
138+
* the server provides a `Retry-After` value we honour it up to
139+
* {@link MAX_RETRY_AFTER_MS}; a longer requested wait is treated as not worth
140+
* retrying and yields `give-up`. Without a usable header, we fall back to
141+
* exponential backoff with full jitter, capped at {@link MAX_BACKOFF_MS}.
142+
*/
143+
export function getRetryDecision(error: unknown, attempt: number, maxRetries: number): RetryDecision {
144+
if (attempt >= maxRetries || !isRateLimitError(error)) {
145+
return { kind: 'give-up' };
146+
}
147+
const retryAfter = getRetryAfterMs(error);
148+
if (retryAfter !== null) {
149+
// The server told us exactly when to retry. Honour it within reason;
150+
// beyond the ceiling the wait is not worth a retry slot.
151+
return retryAfter > MAX_RETRY_AFTER_MS ? { kind: 'give-up' } : { kind: 'retry', delayMs: retryAfter };
152+
}
153+
return { kind: 'retry', delayMs: getBackoffDelayMs(attempt) };
154+
}
155+
156+
/** Returns whether the given error is an HTTP 429 (rate limit) transport error. */
157+
function isRateLimitError(error: unknown): boolean {
158+
return isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR) && error.context.statusCode === 429;
159+
}
160+
161+
/**
162+
* Computes a jittered exponential backoff delay for the given attempt, capped at
163+
* {@link MAX_BACKOFF_MS}.
164+
*/
165+
export function getBackoffDelayMs(attempt: number): number {
166+
const exponential = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
167+
// Full jitter: a random delay in [0, exponential] to spread out retries and
168+
// avoid a thundering herd against the rate limiter.
169+
return Math.round(Math.random() * exponential);
170+
}
171+
172+
/**
173+
* Parses the `Retry-After` header from a rate-limit error into milliseconds, or
174+
* returns `null` when the header is absent or unparseable.
175+
*/
176+
function getRetryAfterMs(error: unknown): number | null {
177+
if (!isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) {
178+
return null;
179+
}
180+
const headerValue = error.context.headers?.get('retry-after');
181+
if (!headerValue) {
182+
return null;
183+
}
184+
185+
// `Retry-After` may be a number of seconds or an HTTP-date.
186+
const seconds = Number(headerValue);
187+
if (Number.isFinite(seconds)) {
188+
return Math.max(0, seconds * 1000);
189+
}
190+
const dateMs = Date.parse(headerValue);
191+
if (Number.isFinite(dateMs)) {
192+
return Math.max(0, dateMs - Date.now());
193+
}
194+
return null;
195+
}
196+
197+
/**
198+
* Sleeps for the given duration, resolving early (without rejecting) if the
199+
* optional abort signal fires. Resolving rather than throwing lets the retry
200+
* loop re-check `signal.aborted` and surface the original error.
201+
*/
202+
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
203+
if (signal?.aborted) {
204+
return Promise.resolve();
205+
}
206+
return new Promise(resolve => {
207+
const onAbort = () => {
208+
clearTimeout(timeout);
209+
resolve();
210+
};
211+
const timeout = setTimeout(() => {
212+
signal?.removeEventListener('abort', onAbort);
213+
resolve();
214+
}, ms);
215+
signal?.addEventListener('abort', onAbort, { once: true });
216+
});
217+
}

clients/js/src/cli/utils.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
createClient,
1616
createKeyPairSignerFromBytes,
1717
createNoopSigner,
18-
createSolanaRpc,
1918
createSolanaRpcSubscriptions,
2019
extendClient,
2120
flattenTransactionPlan,
@@ -35,7 +34,12 @@ import {
3534
TransactionPlan,
3635
TransactionSigner,
3736
} from '@solana/kit';
38-
import { solanaRpc, TransactionPlannerConfig } from '@solana/kit-plugin-rpc';
37+
import {
38+
rpcGetMinimumBalance,
39+
rpcTransactionPlanner,
40+
rpcTransactionPlanSendingExecutor,
41+
TransactionPlannerConfig,
42+
} from '@solana/kit-plugin-rpc';
3943
import { identity, payer } from '@solana/kit-plugin-signer';
4044
import { Command } from 'commander';
4145
import picocolors from 'picocolors';
@@ -56,6 +60,7 @@ import {
5660
RpcOption,
5761
WriteOptions,
5862
} from './options';
63+
import { createRetryingSolanaRpc, RetryingRpcConfig } from './rpc';
5964

6065
const LOCALHOST_URL = 'http://127.0.0.1:8899';
6166
const DATA_SOURCE_OPTIONS =
@@ -85,21 +90,39 @@ export async function getClient(options: GlobalOptions) {
8590
const rpcSubscriptionsUrl = getRpcSubscriptionsUrl(rpcUrl, configs);
8691
const [identitySigner, payerSigner] = await getKeyPairSigners(options, configs);
8792

93+
// We build the RPC connection ourselves rather than using the all-in-one
94+
// `solanaRpc` plugin because that plugin does not expose a hook for a custom
95+
// transport, and we need a transport that retries on HTTP 429 responses to
96+
// survive rate-limited endpoints. We therefore attach our retrying RPC (and
97+
// its subscriptions) directly and apply the RPC plugin's constituents.
98+
const rpc = createRetryingSolanaRpc(rpcUrl, getRetryingRpcConfig());
99+
const rpcSubscriptions = createSolanaRpcSubscriptions(rpcSubscriptionsUrl);
100+
const transactionConfig = getTransactionConfig(options);
101+
88102
return createClient()
89103
.use(payer(payerSigner))
90104
.use(identity(identitySigner))
91-
.use(
92-
solanaRpc({
93-
rpcUrl,
94-
rpcSubscriptionsUrl,
95-
transactionConfig: getTransactionConfig(options),
96-
}),
97-
)
105+
.use(client => extendClient(client, { rpc, rpcSubscriptions }))
106+
.use(rpcGetMinimumBalance())
107+
.use(rpcTransactionPlanner(transactionConfig))
108+
.use(rpcTransactionPlanSendingExecutor({ estimateResourceLimits: transactionConfig.estimateResourceLimits }))
98109
.use(programMetadataProgram())
99110
.use(cliConfigs(configs))
100111
.use(cliRunOrExport(options));
101112
}
102113

114+
/**
115+
* Shared configuration for the CLI's retrying RPC. Surfaces a warning whenever a
116+
* request is rate limited and retried, so a paused command does not appear to
117+
* hang. Used by both {@link getClient} and {@link getReadonlyClient}.
118+
*/
119+
function getRetryingRpcConfig(): RetryingRpcConfig {
120+
return {
121+
onRetry: ({ delayMs }) =>
122+
logWarning(`RPC rate limited (HTTP 429), retrying in ${(delayMs / 1000).toFixed(1)}s...`),
123+
};
124+
}
125+
103126
/**
104127
* Builds the transaction planner config for the requested transaction version.
105128
* The config shape is discriminated by `version`: legacy and version 0
@@ -213,7 +236,7 @@ export function getReadonlyClient(options: RpcOption): ReadonlyClient {
213236
const rpcSubscriptionsUrl = getRpcSubscriptionsUrl(rpcUrl, configs);
214237
return {
215238
configs,
216-
rpc: createSolanaRpc(rpcUrl),
239+
rpc: createRetryingSolanaRpc(rpcUrl, getRetryingRpcConfig()),
217240
rpcSubscriptions: createSolanaRpcSubscriptions(rpcSubscriptionsUrl),
218241
};
219242
}

0 commit comments

Comments
 (0)