You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Track the current RPC rate-limit handling design for Gateway (Solana + Ethereum) as introduced/refined in PR #656, so the behavior is documented somewhere durable without committing a standalone doc file into the repo.
This is a tracking / documentation issue. The longer-term cleanup is captured separately in #657 (viem migration), which would replace most of the Ethereum-side custom code below — see it as the follow-up to this design.
RPC Rate‑Limit Handling
How Gateway reacts when an RPC endpoint answers a request with HTTP 429 Too Many Requests.
Covers both Solana (@solana/web3.jsConnection) and Ethereum (ethersProvider).
Source: src/rpc/rpc-connection-interceptor.ts, wired up in src/chains/solana/solana.ts and src/chains/ethereum/ethereum.ts.
TL;DR
Every Connection / Provider is wrapped in a Proxy that intercepts every method call.
Solana reads (get… methods) are retried up to 3 times, 5 s apart on a 429, because web3.js's own
retry budget is short. After retries are exhausted (or on a write) it throws a clean statusCode: 429.
Ethereum does NOT retry in the Proxy — ethers already retries 429s internally (up to throttleLimit,
default 12, with Retry-After support). The Proxy only normalizes the 429 into a clean statusCode: 429 error so routes surface it instead of masking it as a 0 balance or a 500.
Writes (send…) are never retried on either chain — they fail fast.
The asymmetry is deliberate: Solana's client retries too little, Ethereum's retries plenty. We add a retry only
where it's missing, and otherwise just translate the error.
Why the two chains differ
Solana — web3.js 1.98.2
Ethereum — ethers 5.8.0
Client's built‑in 429 retry
up to 5 attempts, exp backoff 500ms→1s→2s→4s (~7.5 s), noRetry-After
up to throttleLimit = 12 attempts, exp backoff, honorsRetry-After
Toggle
disableRetryOnRateLimit
throttleLimit
Gateway Proxy adds
retry ×3 (5 s each) on reads + normalize
normalize only (no retry)
Rationale
client gives up quickly under sustained load; extra retries help bridge bursts
client already retries heavily; a second layer would only compound blocking into minutes
Verified against the installed sources: web3.js node_modules/@solana/web3.js/lib/index.cjs.js:5054,
ethers @ethersproject/web/lib/index.js:85.
Layer 2 in detail (the Proxy interceptor)
createRateLimitAwareSolanaConnection(connection, rpcUrl) and createRateLimitAwareEthereumProvider(provider, rpcUrl) each return a Proxy whose get trap wraps every function
property. They share the is429Error detector and the clean‑error factory, but differ in retry behavior.
Solana wrapper — retry on reads
constmaxAttempts=isRetryableReadMethod(prop) ? READ_RETRY_DELAYS_MS.length+1 : 1;// 4 or 1for(letattempt=0;attempt<maxAttempts;attempt++){try{returnawaitrealMethod.apply(target,args);// bound to the RAW object, not the proxy}catch(error){if(!is429Error(error))throwerror;// non‑429 → propagate untouchedif(attempt<maxAttempts-1){// retries remain → log + sleep + continueawaitsleep(READ_RETRY_DELAYS_MS[attempt]);// 5000, 5000, 5000continue;}throwcreateRateLimitError(rpcUrl,'solana');// budget exhausted → clean 429}}
Read = method name starts with get… (or, on Ethereum, equals call) — see isRetryableReadMethod.
try{returnawaitrealMethod.apply(target,args);}catch(error){if(!is429Error(error))throwerror;// non‑429 → propagate untouchedthrowcreateRateLimitError(rpcUrl,'ethereum');// 429 → clean error, NO sleep, NO retry}
Shared behavior
Non‑429 errors are never retried or wrapped — they propagate exactly as thrown.
The thrown rate‑limit error carries statusCode: 429, name: 'TooManyRequestsError', and a human message telling
the operator to configure an API‑keyed RPC provider. Fastify turns statusCode: 429 into an HTTP 429 response.
realMethod.apply(target, …) binds to the raw object, so a wrapped method's internal this.foo() calls go to
the unwrapped object — this prevents nested/compounding retries, but also means only the method you call on the
proxy is intercepted (see Gotcha Feat/certs #1).
is429Error detection
A 429 surfaces in many shapes across providers, so detection is intentionally broad:
error.messageincludes'429'|'too many requests'|'rate limit'||error.code===429||error.status===429||error.response.status===429||JSON.stringify(error)includes'"code":429'/'"status":429'
Provider
Typical 429 shape
Matched by
ethers v5
SERVER_ERROR with error.status === 429
error.status === 429
web3.js
Error: 429 Too Many Requests …
message.includes('429' / 'too many requests')
Scenarios by RPC call type
R = retried in the Proxy (Solana reads only). N = normalized to clean 429, not retried in the Proxy. W = write, fail fast.
ethers retries internally; normalized 429 re‑thrown by getBalances
contract.allowance → provider.call
N
normalized; propagated by the allowances route
getTransactionReceipt (receipt polling)
N
normalized; handleTransactionExecution re‑throws the 429
estimateGas
N
normalized; propagated by the estimate‑gas route
sendTransaction
W
normalized to 429 immediately
Worked examples
A. Transient 429 that clears (Solana read)
t=0.0s getAccountInfo() → 429 (after web3.js' own internal retries) attempt 0 warn "Retrying in 5000ms (1/3)"
t=5.0s retry → 429 attempt 1 warn "Retrying in 5000ms (2/3)"
t=10.0s retry → 200 OK attempt 2 ✅ returns the data
Caller never sees an error; added latency ≈ 10 s on top of web3.js' internal backoff.
B. Sustained 429 (Ethereum native balance)
ethers retries internally up to throttleLimit (12) with Retry-After → still 429
→ Proxy does NOT retry → throws TooManyRequestsError(statusCode 429)
→ getBalances() sees statusCode === 429 and re‑throws → HTTP 429 to the client
The balance is not silently reported as 0. (Before this PR, the route caught the error and returned 0/500.)
realMethod.apply(target, …) binds to the raw object. Only the method you call on the proxy is intercepted. If a
high‑level method fans out to several RPC calls internally (e.g. Solana confirmTransaction polling getSignatureStatuses), those inner calls bypass the proxy and are neither retried nor normalized. Coverage relies
on connectors calling connection.getXxx / provider.calldirectly on the wrapped object — which they do.
Gotcha #2 — classification is by name, not semantics
simulateTransaction (Solana) and estimateGas (Ethereum) are read‑only but aren't classified as retryable reads
because their names don't start with get (and aren't call). On Solana they're normalized but not retried; on
Ethereum everything is normalize‑only anyway. The rule is purely lexical, so a future get…‑named mutating method
would be wrongly classified as a read — unlikely, but worth knowing.
Gotcha #3 — Solana retry is fixed delay, no backoff or jitter
Solana retries are a flat 5s, 5s, 5s. Under a sustained limit, parallel reads wake together and re‑hit the
endpoint at once (mild thundering herd). Fine for momentary bursts; the durable fix for sustained limits is an
API‑keyed provider (Helius), which the error message points the operator to.
A single rate‑limited Solana read can block up to ~15 s in the Proxy (on top of web3.js' own backoff) before failing.
Parallel batches (Promise.all) sleep concurrently; a sequential loop adds ~15 s per rate‑limited item until the
first exhausts and throws.
Where it's wired up
Solana:src/chains/solana/solana.ts wraps every Connection it builds (retry + normalize).
Ethereum:src/chains/ethereum/ethereum.ts, plus src/rpc/infura-service.ts and src/rpc/chainstack-service.ts wrap every Provider (normalize only).
Ethereum routes re‑throw statusCode === 429 instead of converting to 0/500: routes/balances.ts, routes/allowances.ts, routes/estimate-gas.ts, and Ethereum.getBalances.
Future direction
A single retry policy would be simpler than today's per‑chain split. The cleanest long‑term shape is multiple
endpoints with automatic failover rather than retrying one rate‑limited endpoint — see the viem migration issue
(#657), which would replace most of this custom code with viem's fallback transport (built‑in 429 retry + Retry-After + latency/stability ranking across providers).
Purpose
Track the current RPC rate-limit handling design for Gateway (Solana + Ethereum) as introduced/refined in PR #656, so the behavior is documented somewhere durable without committing a standalone doc file into the repo.
This is a tracking / documentation issue. The longer-term cleanup is captured separately in #657 (viem migration), which would replace most of the Ethereum-side custom code below — see it as the follow-up to this design.
RPC Rate‑Limit Handling
How Gateway reacts when an RPC endpoint answers a request with HTTP
429 Too Many Requests.Covers both Solana (
@solana/web3.jsConnection) and Ethereum (ethersProvider).Source:
src/rpc/rpc-connection-interceptor.ts, wired up insrc/chains/solana/solana.tsandsrc/chains/ethereum/ethereum.ts.TL;DR
Connection/Provideris wrapped in a Proxy that intercepts every method call.get…methods) are retried up to 3 times, 5 s apart on a 429, because web3.js's ownretry budget is short. After retries are exhausted (or on a write) it throws a clean
statusCode: 429.ethersalready retries 429s internally (up tothrottleLimit,default 12, with
Retry-Aftersupport). The Proxy only normalizes the 429 into a cleanstatusCode: 429error so routes surface it instead of masking it as a0balance or a500.send…) are never retried on either chain — they fail fast.The asymmetry is deliberate: Solana's client retries too little, Ethereum's retries plenty. We add a retry only
where it's missing, and otherwise just translate the error.
Why the two chains differ
500ms→1s→2s→4s(~7.5 s), noRetry-AfterthrottleLimit= 12 attempts, exp backoff, honorsRetry-AfterdisableRetryOnRateLimitthrottleLimitLayer 2 in detail (the Proxy interceptor)
createRateLimitAwareSolanaConnection(connection, rpcUrl)andcreateRateLimitAwareEthereumProvider(provider, rpcUrl)each return aProxywhosegettrap wraps every functionproperty. They share the
is429Errordetector and the clean‑error factory, but differ in retry behavior.Solana wrapper — retry on reads
get…(or, on Ethereum, equalscall) — seeisRetryableReadMethod.maxAttempts = 4(1 initial + 3 retries); everything else gets1(fail fast).Ethereum wrapper — normalize only, no retry
Shared behavior
statusCode: 429,name: 'TooManyRequestsError', and a human message tellingthe operator to configure an API‑keyed RPC provider. Fastify turns
statusCode: 429into an HTTP 429 response.realMethod.apply(target, …)binds to the raw object, so a wrapped method's internalthis.foo()calls go tothe unwrapped object — this prevents nested/compounding retries, but also means only the method you call on the
proxy is intercepted (see Gotcha Feat/certs #1).
is429ErrordetectionA 429 surfaces in many shapes across providers, so detection is intentionally broad:
SERVER_ERRORwitherror.status === 429error.status === 429Error: 429 Too Many Requests …message.includes('429' / 'too many requests')Scenarios by RPC call type
R= retried in the Proxy (Solana reads only).N= normalized to clean 429, not retried in the Proxy.W= write, fail fast.Solana
getBalance/getAccountInfo/getMultipleAccountsInfogetProgramAccounts/getTokenAccountsByOwner/getLatestBlockhash/getSignatureStatusessimulateTransactionget…→ not retried; normalized to 429sendRawTransaction/sendTransactionconfirmTransactionEthereum
getBalance(native)contract.balanceOf→provider.call(token balance)getBalancescontract.allowance→provider.callgetTransactionReceipt(receipt polling)handleTransactionExecutionre‑throws the 429estimateGassendTransactionWorked examples
A. Transient 429 that clears (Solana read)
Caller never sees an error; added latency ≈ 10 s on top of web3.js' internal backoff.
B. Sustained 429 (Ethereum native balance)
The balance is not silently reported as
0. (Before this PR, the route caught the error and returned0/500.)C. Write under load (Solana send)
D. Non‑429 error (any call)
Gotchas & caveats
Gotcha #1 — interception is single‑layer
realMethod.apply(target, …)binds to the raw object. Only the method you call on the proxy is intercepted. If ahigh‑level method fans out to several RPC calls internally (e.g. Solana
confirmTransactionpollinggetSignatureStatuses), those inner calls bypass the proxy and are neither retried nor normalized. Coverage relieson connectors calling
connection.getXxx/provider.calldirectly on the wrapped object — which they do.Gotcha #2 — classification is by name, not semantics
simulateTransaction(Solana) andestimateGas(Ethereum) are read‑only but aren't classified as retryable readsbecause their names don't start with
get(and aren'tcall). On Solana they're normalized but not retried; onEthereum everything is normalize‑only anyway. The rule is purely lexical, so a future
get…‑named mutating methodwould be wrongly classified as a read — unlikely, but worth knowing.
Gotcha #3 — Solana retry is fixed delay, no backoff or jitter
Solana retries are a flat
5s, 5s, 5s. Under a sustained limit, parallel reads wake together and re‑hit theendpoint at once (mild thundering herd). Fine for momentary bursts; the durable fix for sustained limits is an
API‑keyed provider (Helius), which the error message points the operator to.
Gotcha #4 — added latency (Solana)
A single rate‑limited Solana read can block up to ~15 s in the Proxy (on top of web3.js' own backoff) before failing.
Parallel batches (
Promise.all) sleep concurrently; a sequential loop adds ~15 s per rate‑limited item until thefirst exhausts and throws.
Where it's wired up
src/chains/solana/solana.tswraps everyConnectionit builds (retry + normalize).src/chains/ethereum/ethereum.ts, plussrc/rpc/infura-service.tsandsrc/rpc/chainstack-service.tswrap everyProvider(normalize only).statusCode === 429instead of converting to0/500:routes/balances.ts,routes/allowances.ts,routes/estimate-gas.ts, andEthereum.getBalances.Future direction
A single retry policy would be simpler than today's per‑chain split. The cleanest long‑term shape is multiple
endpoints with automatic failover rather than retrying one rate‑limited endpoint — see the viem migration issue
(#657), which would replace most of this custom code with viem's
fallbacktransport (built‑in 429 retry +Retry-After+ latency/stability ranking across providers).