Skip to content

Fail over fork RPC endpoints with an archive-aware preflight - #289

Open
thedavidmeister wants to merge 5 commits into
mainfrom
rpc-failover-preflight
Open

Fail over fork RPC endpoints with an archive-aware preflight#289
thedavidmeister wants to merge 5 commits into
mainfrom
rpc-failover-preflight

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Closes #288.

Foundry maps one [rpc_endpoints] alias to exactly one URL and --fork-retries only retries that same URL, so a deterministic upstream failure — the exhausted drpc plan quota that reddened rain.extrospection / rain.factory.deploy, a pruning node, a dead host — cannot be recovered inside forge. This recovers it one step earlier: a preflight picks a candidate that is healthy right now and binds it to <NETWORK>_RPC_URL before forge starts.

What's here

  • rainix-static rpc-preflight (rainix-static/src/rpc_preflight.rs) — all the logic, per the "tooling is Rust, never bash" rule. 20 unit tests, run inside the nix build.
  • .github/actions/rpc-preflight/action.yml — house pattern: nix run "path:$GITHUB_ACTION_PATH/../../..#rainix-static", so the check version always matches the action version regardless of any RAINIX_SHA the caller pins.
  • Wired into the three reusables that set fork RPC env — rainix-sol-test.yaml, rainix-tag-release.yaml, rainix-manual-sol-artifacts.yamlreplacing the static secrets.X || vars.X mapping in each.
  • flake.nix: rainix-static is now wrapped with the pinned curl and git plus a CA bundle. The composite runs nix run outside any devshell, so ambient PATH is whatever the runner image ships. Verified hermetic — the nix-built binary completes a full selection under env -i PATH=/nonexistent.

Merged pool, not a precedence chain

Per the ruling — "i didn't say fallback if not set i said merge into a list of urls that are all used". The secret and the variable each hold a newline-separated list, and the candidate pool is the concatenation of both plus hardcoded defaults:

order source holds
1 secret RPC_URL_<NET>_FORK keyed/paid URLs (masked)
2 variable RPC_URL_<NET>_FORK public keyless URLs (visible, greppable)
3 hardcoded defaults measured keyless archive endpoints

A URL in the variable is tried even when the secret is set — a non-empty earlier source never causes a later one to be skipped, it only orders them. Ordering is secret-first because that is the paid/dedicated endpoint; public ones are the safety net. In the demo below secret[0], secret[1] and variable[0] are all rejected and variable[1] is selected, which is only reachable under the merged reading.

Separator = newline, because it cannot occur inside a URL (no escaping), GitHub's secret/variable editors take multi-line values natively, and the runner registers each line of a multi-line secret as a separately masked value. A single bare URL parses as a one-element list, which is exactly what these secrets hold today — no configuration change is required for this PR to be an improvement; the hardcoded defaults alone already fix the outage. # starts a comment so a dead candidate can be parked with a note.

Foundry consumes exactly one URL per alias, so "all used" is implemented as all are candidates the preflight will actually try. See the open question at the bottom.

The probe is archive-aware, and that is the load-bearing part

eth_blockNumber is useless here: it passes on a pruning node that then fails the suite. So is eth_getCode alone — base.meowrpc.com serves historical code but answers every eth_call with "method not supported". Per candidate, fail-cheapest-first:

  1. eth_chainId against the expected chain id — a wrong-chain candidate would silently fork the wrong network, worse than a red job.
  2. eth_getBalance at the probe block — historical account state, what forge's fork backend fetches first.
  3. eth_call at the probe block — historical execution, and the result must be a full 32-byte word (a 0x answer means the node served empty state).

3 consecutive full passes required. 1rpc.io/arb passed the historical probe 1 time in 5 and 1rpc.io/base flipped from archive to state is pruned within twenty minutes — these hosts round-robin over a mix of archive and pruning backends, so a single sample qualifies an endpoint that dies partway through a suite issuing thousands of historical reads.

Probe block = the deepest block any repo in the org pins for that network, because the question is not "is this an archive node" but "can it serve my fork block":

network probe block why
arbitrum 280,000,000 rain.tofu.erc20-decimals (~20 months)
base 1 and 39,000,000 rain.deploy findDeployBlock binary-searches from genesis; the second block stops a node holding only early state from passing
base_sepolia 38,000,000 rain.metadata metaboard start block
flare 31,000,000 rain.flare, ~19 test files (~23 months)
polygon 82,000,000 rain.metadata metaboard start block
ethereum latest every Ethereum fork in the org is at latest; a pruning node is fine and should not be excluded
hyperevm latest latest-only in every consumer

Note base_sepolia and polygon have no two-arg createSelectFork(url, block) anywhere — their archive requirement is hidden inside LibRainDeploy.isStartBlock, which forks at latest and then vm.rollFork()s backwards.

archive: 'false' on the deploy path (rainix-manual-sol-artifacts): a broadcast only reads head state, so requiring archive there would reject a healthy pruning endpoint for nothing. The _FORK suffix on the secret is a misnomer on that path.

How the no-leak guarantee works

Not a scrubbing pass — those get bypassed by the next edit. It is structural, in three layers:

  1. struct Url(String) has no Display, and its Debug prints <redacted>. No format string anywhere in the crate can put a URL on stdout or stderr, including a future edit that adds a {:?} to a log line. The inner value is reachable only via expose(), which has exactly three call sites: curl's argv, the write to $GITHUB_ENV, and the ::add-mask:: command in (3) — whose entire purpose is that the runner redacts the value it is handed, including in the command line itself. There is deliberately no stdout mode: invoked without a file to write to, the subcommand fails rather than falling back to printing.
  2. Every log line is a Source plus a Reason, both closed enums over fixed text. Reason's Display is fixed strings plus integers we produced ourselves (a JSON-RPC code, an HTTP status, a curl exit, a chain id, a block). Upstream response bodies are classified and then dropped — an endpoint that echoes the request URL back inside its error message, or serves an HTML error page containing it, cannot leak it. curl's stderr is Stdio::null()ed because curl writes the request host into its own error text.
  3. ::add-mask:: on any secret-sourced selection before it reaches $GITHUB_ENV. Redundant with the runner's own per-line masking of multi-line secrets, but free — and it does not help for the case that actually matters, which is a keyed URL misconfigured into vars. (not auto-masked). That case is covered by (1) and (2).

Candidate URLs also travel to the composite as env vars, not with: inputs, because GitHub renders a composite's resolved inputs into the log.

There is a test asserting Url's debug is <redacted> and one asserting no Reason variant renders a http:// or https://.

Blast radius containment

  • Only networks the repo references are probed. A demand scan over git ls-files looks for <NET>_RPC_URL (the foundry.toml alias form) and RPC_URL_<NET>_FORK (the raw-secret form cyclo.sol reads via vm.envString). An unmentioned network is never probed, never exported, and can never fail the job. A test asserts BASE_SEPOLIA_RPC_URL does not drag base in.
  • A network with no candidates at all is a skip, not a failure — byte-identical to today's behaviour (env stays unset).
  • The selected URL is exported under both <NET>_RPC_URL and RPC_URL_<NET>_FORK. The second matters: once the secret can hold a list, repos reading the raw secret name would otherwise get a multi-line string createSelectFork cannot use.

Riding along

RPC_URL_HYPEREVM_FORK was declared in rainix-manual-sol-artifacts.yaml only, so a repo with hyperevm in [rpc_endpoints] could deploy but its fork tests and tag-release verification both failed "env var not found". Added to rainix-sol-test.yaml, rainix-sol.yaml (declaration and the explicit forward — that forward is a hard allowlist, secrets: inherit upstream does not get past it) and rainix-tag-release.yaml. README's documented env list was also missing ETHEREUM_RPC_URL and HYPEREVM_RPC_URL.

Demonstration

Local end-to-end run against a synthetic consumer repo whose foundry.toml declares arbitrum and whose test reads RPC_URL_BASE_SEPOLIA_FORK directly. The secret list is [an exhausted-plan upstream with ?dkey=SUPERSECRETKEY123, a pruning node], the variable list is [publicnode (archive token-gated), thirdweb]. Polygon is configured but not referenced by the repo. The first two upstreams are local mocks replaying the verbatim drpc and geth error bodies; everything else is a real public endpoint.

rpc-preflight: arbitrum: secret[0] rejected: quota exhausted / rate limited (rpc error -32001)
rpc-preflight: arbitrum: secret[1] rejected: not archive: no historical state at block 280000000 (rpc error -32000)
rpc-preflight: arbitrum: variable[0] rejected: not archive: no historical state at block 280000000 (rpc error -32602)
::warning::rpc-preflight: arbitrum fell back to variable[1] after 3 unhealthy candidate(s)
rpc-preflight: arbitrum: SELECTED variable[1] (chain 42161, archive at block 280000000, 3/3 samples)
rpc-preflight: base_sepolia: SELECTED default[0] (chain 84532, archive at block 38000000, 3/3 samples)
rpc-preflight: clean
exit=0

--- $GITHUB_ENV written by the step ---
ARBITRUM_RPC_URL=https://42161.rpc.thirdweb.com
RPC_URL_ARBITRUM_FORK=https://42161.rpc.thirdweb.com
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
RPC_URL_BASE_SEPOLIA_FORK=https://sepolia.base.org

=== LEAK CHECK: does anything the step printed contain the secret's key? ===
clean: 'SUPERSECRETKEY123' appears 0 times in the step log
host substring occurrences in log: 0

Every rejection is named by source with a typed reason. Polygon is absent because the repo does not reference it. base_sepolia had nothing configured and landed on a hardcoded default — the case of a repo that sets no secret at all today.

Total-failure path — every candidate for a required network unreachable (an outbound proxy pointed at a closed port, so the hardcoded defaults die too):

rpc-preflight: arbitrum: secret[0] rejected: unreachable (curl exit 7)
rpc-preflight: arbitrum: variable[0] rejected: unreachable (curl exit 7)
rpc-preflight: arbitrum: default[0] rejected: unreachable (curl exit 7)
rpc-preflight: arbitrum: default[1] rejected: unreachable (curl exit 7)
rpc-preflight: arbitrum: default[2] rejected: unreachable (curl exit 7)
::error::rpc-preflight: arbitrum: no healthy endpoint among 5 candidate(s) for chain 42161 (archive at block 280000000). Every candidate and why it was rejected is listed above by SOURCE; add a working URL to the RPC_URL_ARBITRUM_FORK secret (keyed) or variable (public, newline-separated).
rpc-preflight: base_sepolia: default[0] rejected: unreachable (curl exit 7)
...
::error::rpc-preflight: no healthy RPC endpoint for: arbitrum, base_sepolia
exit=1

Note it does not stop at the first dead network — every required network is reported, so one run tells you everything that needs fixing. The secret here was https://keyed.example/?dkey=SUPERSECRETKEY123; neither the key, nor the host, nor the variable's host appears anywhere in that output.

Closing the loop with real forge — same pin rain.extrospection uses, isolated HOME so there is no warm fork cache:

### FORGE, with the URL the preflight REJECTED as not-archive (public arb1) ###
Ran 1 test for test/Fork.t.sol:ForkTest
[FAIL: EVM error; database error: failed to get account for 0xA4b000000000000000000073657175656e636572: server returned an error response: error code -32000: metadata is not found, 402243830] testForkAtPinnedBlock() (gas: 0)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped

### FORGE, with the URL the preflight SELECTED ###
Ran 1 test for test/Fork.t.sol:ForkTest
[PASS] testForkAtPinnedBlock() (gas: 16218)
Suite result: ok. 1 passed; 0 failed; 0 skipped

That failed to get account for 0xA4b0…sequencer is precisely the cryptic mid-suite error this replaces with secret[0] rejected: quota exhausted.

Checks

  • cargo test — 34 pass (20 new), cargo fmt --check and cargo clippy -D clippy::all clean.
  • nix develop -c pre-commit run --all-files — yamlfmt, prettier-rainix, shellcheck, statix, taplo, nil, nixfmt, deadnix, denofmt all pass. The rustfmt-conditional hook fails identically on pristine main (cargo metadata looks for a Cargo.toml at the repo root, which rainix does not have); it is not exercised by CI, where cargo-fmt is off PATH and the hook no-ops. Not touched here — separate issue.

Open question

Foundry consumes exactly one URL per alias, so "all used" is implemented as every URL in either list is a candidate the preflight will actually try, which is what the words say. True simultaneous multi-upstream load balancing would need a proxy sitting in front of forge, not a URL list. Flagging rather than silently choosing, in case that was the intent.

Two follow-ups deliberately not in this PR, one of which is red on main right now:

  • sepolia is not covered, and it is the reason main is red. rainix (ubuntu-latest, rainix-sol-artifacts) has failed on every main run since 2026-07-24 (green on 07-16), including aa789b99, which this branch is cut from. The log is the same failure class this PR fixes, in its purest form:

    ++ forge script script/Deploy.sol:Deploy -vvvvv --slow --rpc-url '' --etherscan-api-key *** --resume
    Error: Internal transport error: Connection refused (os error 111)
    Deploy failed, retrying in 5 seconds... (attempt 7)
    ...
    Deploy failed after 10 attempts, aborting.
    

    --rpc-url ''secrets.CI_DEPLOY_SEPOLIA_RPC_URL || vars.CI_DEPLOY_SEPOLIA_RPC_URL resolves empty, and flake.nix's until do_deploy; do sleep 5 loop then re-hits the empty URL ten times over ~50s before failing anyway. A candidate pool with a hardcoded default would make this green with zero configuration.

    I did not fold sepolia in, because doing so makes two decisions that are yours, not mine: (1) sepolia is the one network outside the RPC_URL_<NET>_FORK scheme — secret CI_DEPLOY_SEPOLIA_RPC_URL → env ETH_RPC_URL and CI_DEPLOY_SEPOLIA_RPC_URL, with flake.nix hardcoding --rpc-url ${ETH_RPC_URL}, so folding it in either enshrines that naming or migrates it; and (2) that job broadcasts with DEPLOYMENT_KEY, so having a hardcoded default pick the endpoint for a real transaction is a materially bigger call than picking one for a fork read. Say which way you want it and it is a small follow-up. It is also block-pinned by a repo variable (CI_FORK_SEPOLIA_BLOCK_NUMBER), so its archive depth is configuration I cannot see from here.

  • flake.nix's rainix-sol-artifacts retry loop is the other half of that log: ten retries with --resume against the same endpoint is structurally identical to Foundry's --fork-retries and just as useless against a deterministic failure. Classifying the error once and either failing fast or moving to another candidate is the same fix as this PR, applied to the deploy task.

Foundry maps one [rpc_endpoints] alias to exactly one URL and --fork-retries
only retries that same URL, so a deterministic upstream failure — an exhausted
plan quota, a pruning node, a dead host — cannot be recovered inside forge.

rainix-static rpc-preflight builds a merged candidate pool per network (the
RPC_URL_<NET>_FORK secret's newline-separated list, then the variable's, then
hardcoded public archive defaults), probes each with chain id plus historical
account state and a historical eth_call at the deepest block any repo in the
org pins for that network, and exports the first healthy one as
<NETWORK>_RPC_URL. Every entry in every source is a candidate; the sources only
set the order.

Candidate URLs are structurally unprintable: the Url newtype has no Display and
a Debug of <redacted>, every log line is a Source label plus a closed Reason
enum, and the only sinks are curl's argv and $GITHUB_ENV.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Jul 25, 2026
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e8851cb8-a945-4d72-a6cf-50f36b778579

📥 Commits

Reviewing files that changed from the base of the PR and between aa789b9 and 64f7dd8.

📒 Files selected for processing (10)
  • .github/actions/rpc-preflight/action.yml
  • .github/workflows/rainix-manual-sol-artifacts.yaml
  • .github/workflows/rainix-sol-test.yaml
  • .github/workflows/rainix-sol.yaml
  • .github/workflows/rainix-tag-release.yaml
  • .github/workflows/test.yml
  • README.md
  • flake.nix
  • rainix-static/src/main.rs
  • rainix-static/src/rpc_preflight.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rpc-failover-preflight

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

thedavidmeister and others added 4 commits July 25, 2026 21:34
rainix has no fork suite of its own, so the reusables' RPC wiring was never
exercised here and every bug in it surfaced downstream in a consumer's red CI.
rainix's own sources name every <NETWORK>_RPC_URL, so the demand scan selects
all of them: the job runs the composite on a real runner and asserts each
archive network got bound, without echoing a value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured today: rpc.hyperliquid.xyz/evm, rpc.hyperlend.finance and
hyperliquid.drpc.org all report chain 999 and answer totalSupply() on WHYPE at
0x5555…5555, so hyperevm no longer has to depend on a configured secret to have
any candidate at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parse_chain_id, probe_blocks, block_tag and check_call_result were inline in
probe(), which needs a live endpoint, so nothing covered them: the archive-vs-
latest branch, the hex block tag and the 32-byte word check could all be
changed without a test noticing. They are pure, so lift them out and test them
directly — the block-tag test caught a wrong hex constant on the way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister added the ai:ready AI vetter: passes review, ready for human decision label Jul 28, 2026
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

🤖 ai:vetter
Reviewed 64f7dd8: ready — closes #288 — merged-pool failover implemented per the human's all-candidates ruling (secret+vars+defaults, newline lists, dedup-first); no-leak is structural and verified (Url no-Display/Debug=redacted, expose() at exactly curl-argv/GITHUB_ENV/add-mask, closed Reason enum, curl stderr nulled, no stdout fallback); archive probe = chainId+getBalance+eth_call x3 samples at deepest org pins; demand scan contains blast radius (substring-safety test); all 20 claimed unit tests exist; wiring replaces static mapping in all three reusables + HYPEREVM forward added; body's flagged open question (all-candidates vs load-balancing intent) is for the human to confirm
cost 780 — org-wide CI secret-handling failover

@thedavidmeister thedavidmeister added ai:design AI vetter: raises a design question and removed ai:ready AI vetter: passes review, ready for human decision labels Jul 28, 2026
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

🤖 ai:producer
Design-question: Bootstrap deadlock, not a code defect. The new composite action is referenced as rainlanguage/rainix/.github/actions/rpc-preflight@main -- the house convention every one of the 9 existing actions in this repo uses -- so it cannot resolve on the PR that introduces it. The rpc-preflight job dies in 3s with: Can't find action.yml, action.yaml or Dockerfile for action rainlanguage/rainix/.github/actions/rpc-preflight@main. That failure also leaves the fork RPC env unset, so rainix (ubuntu-latest, rainix-sol-artifacts) fails downstream with Internal transport error: Connection refused (os error 111) -- one cause, both reds. No code change within this PR's scope can green it, and every future composite action added here hits the same wall. Ruling needed: (a) accept the one-time bootstrap red and merge as-is, since the ref resolves the instant it is on main, or (b) split into two PRs -- land the action plus the rainix-static rpc-preflight subcommand first with no reusable wiring (green, because nothing references it yet), then wire the three reusables in a follow-up (green, because the action now exists on main). This PR is the root fix for the org-wide fork-RPC outage currently reddening rain.factory.deploy 4/5/6, rain.deploy 19, cyclo.sol 56 and rain.flare 137/170, so the ruling unblocks that whole cohort.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:design AI vetter: raises a design question

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fork RPC has no failover: one vendor's exhausted plan quota reds CI org-wide

1 participant