Skip to content

Rotate ENS lookups across free RPCs, fall back to paid on 429 - #10

Closed
frolic wants to merge 2 commits into
worker-apifrom
rpc-rotation
Closed

Rotate ENS lookups across free RPCs, fall back to paid on 429#10
frolic wants to merge 2 commits into
worker-apifrom
rpc-rotation

Conversation

@frolic

@frolic frolic commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #9. Cuts paid RPC cost by rotating ENS lookups across free public RPCs, only using the paid endpoint (ETHEREUM_RPC_URL, Alchemy) when the free ones are all failing/rate-limited.

Approach

viem's fallback transport already does the hard part — I confirmed in its source that it advances to the next transport on any non-user error (429s roll over) and retries each transport zero times, only throwing once the last one fails. So the whole feature is:

  • api/src/rpcUrls.ts — the free RPC pool (easy to tune).
  • api/src/ethereumTransport.tsfallback([...shuffle(freeRpcs), paid]).
  • router.ts — use it instead of http(env.ETHEREUM_RPC_URL).

Per-request shuffle spreads load across the pool — without it fallback always tries the first URL first and would hammer it into rate limits. A stateless Worker can't do true round-robin (no shared counter), so randomization is the pragmatic equivalent.

Not rpc-racer's racing

Took the idea from rpc-racer, not its approach: it fans each request out to N RPCs in parallel and races them. That multiplies request volume to the free tiers (more 429s, more usage) — counterproductive when the goal is cost. Sequential fallback hits exactly one endpoint per call in the happy path.

Tests

api/src/ethereumTransport.test.ts (2 new, 8 total passing):

  • free RPCs healthy → answered by the first (random) free RPC, paid endpoint never touched.
  • all free RPCs 429 → every free RPC tried, then the paid endpoint last.

Paid usage now only occurs on cache misses where the free pool is exhausted (the worker still edge-caches results 24h).

🤖 Generated with Claude Code

Wrap the resolver's viem client in a `fallback` transport over a
randomly-shuffled pool of free public RPCs, with the paid endpoint
(ETHEREUM_RPC_URL) pinned last. viem advances to the next transport on
any non-user error (429s included) and retries each zero times, so the
paid RPC is only hit when every free RPC is failing — cutting paid RPC
cost. Per-request shuffle spreads load across the pool (a stateless
Worker can't do true round-robin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ens-ideas Error Error Jul 9, 2026 9:22pm

Request Review

Replace the guessed RPC list with 7 endpoints actually verified to
resolve ENS forward+reverse in <400ms; several well-known public RPCs
(llamarpc, cloudflare-eth, 1rpc, ankr) were down, rate-limited, or
reverted on the universal-resolver call. Adds scripts/verify-rpcs.ts
(pnpm --filter ./api run verify:rpcs) to re-check the pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@frolic

frolic commented Jul 14, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #21, which brings this forward onto the v2 monorepo API worker (apps/api). This branch was built on the pre-restructure api/src layout; #21 ports the same design — the rotating fallback transport, the chainlist-backed self-refreshing pool, and the /rpcs endpoint — and validates it on a preview stage (41 candidates checked → 16 healthy). The work isn't lost, just rebuilt on the current structure.

@frolic frolic closed this Jul 14, 2026
frolic added a commit that referenced this pull request Jul 15, 2026
Brings **#10** (RPC rotation) and **#11** (chainlist-backed healthy
list) forward onto the v2 monorepo API worker, rebuilt around a cron +
KV.

## How it works
- **`ethereumTransport`** — viem `fallback` across the free RPCs in
random order, paid endpoint (`ETHEREUM_RPC_URL`) last. viem retries each
transport zero times and advances on any non-user error, so a **429
rolls over transparently** — keeping paid usage, and cost, minimal.
Shuffling spreads load (a stateless Worker can't round-robin).
- **Cron (`0 * * * *`)** — health-checks the chainlist candidates and
writes the survivors to **KV**, once for the whole fleet.
- **Request path** — one KV read:
  ```ts
const healthy = (await env.RPCS.get<string[]>(RPCS_KEY, "json")) ?? [];
  transport: ethereumTransport(healthy, env.ETHEREUM_RPC_URL)
  ```
A cold/empty list just means straight to the paid RPC — the transport
already handles that.

## Why cron + KV rather than refreshing on the request path
`caches.default` is **per-colo**. Refreshing lazily meant *every* data
center re-ran the full 41-endpoint health-check pass hourly — roughly
**2k check requests/hour** against the free RPCs the rotation exists to
lean on, which is how you get rate-limited by them. The cron does
**~41/hour**, once. And because a cron runs in a single colo, the list
has to live somewhere global — hence KV, not the Cache API.

## What that deleted
Falling back to the paid RPC on a cold KV removed the seed list, and
with it most of the machinery:
- `rpcUrls.ts` (7-endpoint seed list), `getHealthyRpcs.ts` (cache +
refresh + staleness), `scripts/verify-rpcs.ts`, and a dead `alchemy.run`
esbuild stub in the tests.
- Gone as concepts: `waitUntil`, the `Age` check, the `refreshing`
dedupe flag, and the two competing TTLs.

## Verified on a preview stage
- Cron registered: `['0 * * * *']`; per-stage KV namespace created.
- A triggered refresh wrote the health-checked list to KV (nodereal,
publicnode, mevblocker, regional blxrbdn, …).
- **Cold KV** → resolves via the paid RPC. **Warm** → resolves via the
KV list.
- Typecheck clean; **15/15 tests**, including the cold-KV→paid path in
both the transport unit test and the miniflare integration test (which
now runs with an empty KV).

**Note:** the deploy token needed `Workers KV Storage: Edit` added.

Supersedes #10 and #11.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant