feat: RPC health check with fallback - #24
Conversation
- rpcHealthCheck(): probe any RPC URL with eth_chainId + configurable timeout;
returns typed {healthy, chainId, error, latencyMs}. Supports injected fetch
for reliable testing (including timeout simulation).
- getRpcUrl(): validate VITE_RPC_URL env var at runtime; log a warning and
fall back to https://rpc.ubq.fi if the override is not a valid HTTP(S) URL.
- getHealthyRpcUrl(): checks primary RPC first, then iterates fallback list
until a healthy one is found. Respects local-node mode (no warnings).
- resolveRpcUrl(): dev-mode wrapper that optionally probes for health.
- RpcDiagnostics panel: visible in dev/local-node builds, hidden in prod.
- 16 unit tests covering healthy RPC, non-ok HTTP, RPC error, network throw,
timeout abort, URL validation, and fallback logic.
Closes ubiquity#9
|
Warning @quantum-agent-MGM this pull request is linked to an issue that is already assigned to another user. Please link it to an open issue assigned to you or to an unassigned open issue. |
📝 WalkthroughWalkthroughThis PR hardens RPC endpoint configuration and runtime resilience by adding health-check utilities, URL validation, and failover orchestration. A new 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
tests/rpc-health.test.ts (1)
11-57: ⚡ Quick winAdd coverage for a 200 OK response without
result.That branch decides whether fallback runs, so it’s worth locking down once malformed JSON-RPC payloads are treated as unhealthy.
🧪 Suggested test
+ it("returns healthy=false when the RPC payload is missing result", async () => { + const fetchImpl = () => Promise.resolve(mockResponse({ jsonrpc: "2.0" })); + const result = await rpcHealthCheck("https://rpc.example.com", 1500, fetchImpl); + expect(result.healthy).toBe(false); + expect(result.chainId).toBeNull(); + expect(result.error).toMatch(/Missing eth_chainId result/); + });
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: fd1f2d1f-31ee-47ed-a79b-4bbe28a21c7b
📒 Files selected for processing (6)
src/App.tsxsrc/components/rpc-diagnostics.tsxsrc/constants/config.tssrc/utils/rpc-health.tstests/config.test.tstests/rpc-health.test.ts
| async function check() { | ||
| const [healthResult, url] = await Promise.all([ | ||
| rpcHealthCheck(RPC_URL, 2000), | ||
| resolveRpcUrl(2000), | ||
| ]); | ||
| if (!cancelled) { | ||
| setHealth(healthResult); | ||
| setResolvedUrl(url); | ||
| setLoading(false); |
There was a problem hiding this comment.
Show health for the same URL you render.
Right now the panel displays resolvedUrl, but health comes from the original RPC_URL. If fallback kicks in, the panel can show the fallback URL with the failed primary’s unhealthy status.
🛠️ Suggested fix
-import { isLocalNode, RPC_URL, resolveRpcUrl } from "../constants/config";
-import { rpcHealthCheck, type RpcHealthResult } from "../utils/rpc-health";
+import { DEV_FALLBACK_RPCS, getHealthyRpcUrl, isLocalNode, RPC_URL } from "../constants/config";
+import type { RpcHealthResult } from "../utils/rpc-health";
@@
async function check() {
- const [healthResult, url] = await Promise.all([
- rpcHealthCheck(RPC_URL, 2000),
- resolveRpcUrl(2000),
- ]);
+ const { url, health } = await getHealthyRpcUrl(RPC_URL, DEV_FALLBACK_RPCS, 2000);
if (!cancelled) {
- setHealth(healthResult);
+ setHealth(health);
setResolvedUrl(url);
setLoading(false);
}
}| const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi"; | ||
|
|
||
| export function isValidHttpUrl(url: string): boolean { | ||
| try { | ||
| const u = new URL(url); | ||
| return u.protocol === "http:" || u.protocol === "https:"; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function getRawRpcUrl(): string { | ||
| const override = import.meta.env.VITE_RPC_URL; | ||
| const currentLocation = globalThis.location; | ||
|
|
||
| if (override) { | ||
| if (!isValidHttpUrl(override)) { | ||
| console.warn( | ||
| `[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.` | ||
| ); | ||
| return PUBLIC_FALLBACK_RPC; | ||
| } | ||
| return override; | ||
| } | ||
|
|
||
| if (isDenoDeployHost(currentLocation?.hostname ?? "")) { | ||
| return PUBLIC_FALLBACK_RPC; | ||
| } | ||
|
|
||
| return `${currentLocation?.origin ?? ""}/rpc`; |
There was a problem hiding this comment.
Restore the local-node default RPC.
getRawRpcUrl() never special-cases local-node, so local builds resolve to ${location.origin}/rpc or the public fallback instead of Anvil on localhost:8545.
🛠️ Suggested fix
const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi";
+const LOCAL_NODE_RPC = "http://localhost:8545";
@@
if (override) {
if (!isValidHttpUrl(override)) {
console.warn(
`[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.`
);
- return PUBLIC_FALLBACK_RPC;
+ return isLocalNode ? LOCAL_NODE_RPC : PUBLIC_FALLBACK_RPC;
}
return override;
}
+
+ if (isLocalNode) {
+ return LOCAL_NODE_RPC;
+ }
if (isDenoDeployHost(currentLocation?.hostname ?? "")) {
return PUBLIC_FALLBACK_RPC;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi"; | |
| export function isValidHttpUrl(url: string): boolean { | |
| try { | |
| const u = new URL(url); | |
| return u.protocol === "http:" || u.protocol === "https:"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function getRawRpcUrl(): string { | |
| const override = import.meta.env.VITE_RPC_URL; | |
| const currentLocation = globalThis.location; | |
| if (override) { | |
| if (!isValidHttpUrl(override)) { | |
| console.warn( | |
| `[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.` | |
| ); | |
| return PUBLIC_FALLBACK_RPC; | |
| } | |
| return override; | |
| } | |
| if (isDenoDeployHost(currentLocation?.hostname ?? "")) { | |
| return PUBLIC_FALLBACK_RPC; | |
| } | |
| return `${currentLocation?.origin ?? ""}/rpc`; | |
| const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi"; | |
| const LOCAL_NODE_RPC = "http://localhost:8545"; | |
| export function isValidHttpUrl(url: string): boolean { | |
| try { | |
| const u = new URL(url); | |
| return u.protocol === "http:" || u.protocol === "https:"; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function getRawRpcUrl(): string { | |
| const override = import.meta.env.VITE_RPC_URL; | |
| const currentLocation = globalThis.location; | |
| if (override) { | |
| if (!isValidHttpUrl(override)) { | |
| console.warn( | |
| `[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.` | |
| ); | |
| return isLocalNode ? LOCAL_NODE_RPC : PUBLIC_FALLBACK_RPC; | |
| } | |
| return override; | |
| } | |
| if (isLocalNode) { | |
| return LOCAL_NODE_RPC; | |
| } | |
| if (isDenoDeployHost(currentLocation?.hostname ?? "")) { | |
| return PUBLIC_FALLBACK_RPC; | |
| } | |
| return `${currentLocation?.origin ?? ""}/rpc`; |
| clearTimeout(timer); | ||
|
|
||
| if (!response.ok) { | ||
| return { | ||
| healthy: false, | ||
| chainId: null, | ||
| error: `HTTP ${response.status}`, | ||
| latencyMs: Date.now() - start, | ||
| }; | ||
| } | ||
|
|
||
| const data = (await response.json()) as { | ||
| result?: string; | ||
| error?: { message?: string }; | ||
| }; | ||
|
|
||
| if (data.error) { | ||
| return { | ||
| healthy: false, | ||
| chainId: null, | ||
| error: data.error.message ?? "RPC error", | ||
| latencyMs: Date.now() - start, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| healthy: true, | ||
| chainId: data.result ? BigInt(data.result) : null, | ||
| latencyMs: Date.now() - start, | ||
| }; | ||
| } catch (err) { | ||
| clearTimeout(timer); |
There was a problem hiding this comment.
Keep the timeout active through body parsing.
fetch() can resolve before the body is fully read. Clearing the timer at Line 40 lets response.json() hang forever if the server stalls after sending headers, which blocks fallback and diagnostics.
🛠️ Suggested fix
- clearTimeout(timer);
-
if (!response.ok) {
return {
healthy: false,
chainId: null,
error: `HTTP ${response.status}`,
@@
- } catch (err) {
- clearTimeout(timer);
+ } catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
healthy: false,
chainId: null,
error: message,
latencyMs: Date.now() - start,
};
+ } finally {
+ clearTimeout(timer);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| clearTimeout(timer); | |
| if (!response.ok) { | |
| return { | |
| healthy: false, | |
| chainId: null, | |
| error: `HTTP ${response.status}`, | |
| latencyMs: Date.now() - start, | |
| }; | |
| } | |
| const data = (await response.json()) as { | |
| result?: string; | |
| error?: { message?: string }; | |
| }; | |
| if (data.error) { | |
| return { | |
| healthy: false, | |
| chainId: null, | |
| error: data.error.message ?? "RPC error", | |
| latencyMs: Date.now() - start, | |
| }; | |
| } | |
| return { | |
| healthy: true, | |
| chainId: data.result ? BigInt(data.result) : null, | |
| latencyMs: Date.now() - start, | |
| }; | |
| } catch (err) { | |
| clearTimeout(timer); | |
| if (!response.ok) { | |
| return { | |
| healthy: false, | |
| chainId: null, | |
| error: `HTTP ${response.status}`, | |
| latencyMs: Date.now() - start, | |
| }; | |
| } | |
| const data = (await response.json()) as { | |
| result?: string; | |
| error?: { message?: string }; | |
| }; | |
| if (data.error) { | |
| return { | |
| healthy: false, | |
| chainId: null, | |
| error: data.error.message ?? "RPC error", | |
| latencyMs: Date.now() - start, | |
| }; | |
| } | |
| return { | |
| healthy: true, | |
| chainId: data.result ? BigInt(data.result) : null, | |
| latencyMs: Date.now() - start, | |
| }; | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| return { | |
| healthy: false, | |
| chainId: null, | |
| error: message, | |
| latencyMs: Date.now() - start, | |
| }; | |
| } finally { | |
| clearTimeout(timer); | |
| } |
| return { | ||
| healthy: true, | ||
| chainId: data.result ? BigInt(data.result) : null, | ||
| latencyMs: Date.now() - start, |
There was a problem hiding this comment.
Treat a missing eth_chainId result as unhealthy.
A 200 OK response without result is not a usable RPC endpoint. Returning healthy: true here prevents fallback and can misreport a broken endpoint as healthy.
🛠️ Suggested fix
- return {
- healthy: true,
- chainId: data.result ? BigInt(data.result) : null,
- latencyMs: Date.now() - start,
- };
+ if (typeof data.result !== "string") {
+ return {
+ healthy: false,
+ chainId: null,
+ error: "Missing eth_chainId result",
+ latencyMs: Date.now() - start,
+ };
+ }
+
+ return {
+ healthy: true,
+ chainId: BigInt(data.result),
+ latencyMs: Date.now() - start,
+ };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return { | |
| healthy: true, | |
| chainId: data.result ? BigInt(data.result) : null, | |
| latencyMs: Date.now() - start, | |
| if (typeof data.result !== "string") { | |
| return { | |
| healthy: false, | |
| chainId: null, | |
| error: "Missing eth_chainId result", | |
| latencyMs: Date.now() - start, | |
| }; | |
| } | |
| return { | |
| healthy: true, | |
| chainId: BigInt(data.result), | |
| latencyMs: Date.now() - start, | |
| }; |
Summary
Implements RPC health probing, validation, and fallback for the stake.ubq.fi frontend to prevent silent failures when the configured RPC endpoint is down.
Changes
rpcHealthCheck(): probe any RPC URL witheth_chainId+ configurable timeout; returns typed{healthy, chainId, error, latencyMs}. Supports injectedfetchfor reliable testing (including timeout simulation).getRpcUrl(): validatesVITE_RPC_URLenv var at runtime; logs a warning and falls back tohttps://rpc.ubq.fiif the override is not a valid HTTP(S) URL.getHealthyRpcUrl(): checks primary RPC first, then iterates fallback list until a healthy one is found. Respects local-node mode (no warnings/crashing if anvil is down).resolveRpcUrl(): dev-mode wrapper that optionally probes for health.RpcDiagnosticspanel: visible in dev/local-node builds, hidden in prod.How to test
bun installbun test— all 16 tests passbun run lint— passesbun run build— builds successfullybun run devwith an invalidVITE_RPC_URLto see the console warning and fallback in action.bun run localwithout anvil to see the diagnostic panel showUnhealthywithout crashing.Notes
developmentbranch as required.RPC_URLremains a synchronous export.isDenoDeployHostbehavior (supports both.deno.devand.deno.net).main, skips timeout testing, and lacks the diagnostic panel.Closes #9