Skip to content

feat: RPC health check with fallback - #24

Closed
quantum-agent-MGM wants to merge 1 commit into
ubiquity:developmentfrom
quantum-agent-MGM:feat/rpc-robustness-fallback
Closed

feat: RPC health check with fallback#24
quantum-agent-MGM wants to merge 1 commit into
ubiquity:developmentfrom
quantum-agent-MGM:feat/rpc-robustness-fallback

Conversation

@quantum-agent-MGM

Copy link
Copy Markdown

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 with eth_chainId + configurable timeout; returns typed {healthy, chainId, error, latencyMs}. Supports injected fetch for reliable testing (including timeout simulation).
  • getRpcUrl(): validates VITE_RPC_URL env var at runtime; logs a warning and falls 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/crashing if anvil is down).
  • 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 response, network throw, timeout abort, URL validation, and fallback logic.

How to test

  1. bun install
  2. bun test — all 16 tests pass
  3. bun run lint — passes
  4. bun run build — builds successfully
  5. Run bun run dev with an invalid VITE_RPC_URL to see the console warning and fallback in action.
  6. Run bun run local without anvil to see the diagnostic panel show Unhealthy without crashing.

Notes

  • Targets development branch as required.
  • Backward compatible: RPC_URL remains a synchronous export.
  • Keeps existing isDenoDeployHost behavior (supports both .deno.dev and .deno.net).
  • Competing PR feat: RPC health check with fallback for stake.ubq.fi #19 is stale, targets main, skips timeout testing, and lacks the diagnostic panel.

Closes #9

- 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
@ubiquity-os

ubiquity-os Bot commented May 11, 2026

Copy link
Copy Markdown

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.

@ubiquity-os ubiquity-os Bot closed this May 11, 2026
@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR hardens RPC endpoint configuration and runtime resilience by adding health-check utilities, URL validation, and failover orchestration. A new rpcHealthCheck utility probes RPC endpoints via JSON-RPC eth_chainId with timeout enforcement. The config module refactors to validate the VITE_RPC_URL environment variable, construct appropriate defaults per deployment environment, and offer a getHealthyRpcUrl function that sequentially probes primary and fallback endpoints. A new RpcDiagnostics component displays detected RPC, health status, and chain ID in non-production environments. The App renders this diagnostic panel alongside the main dashboard.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: RPC health checking with fallback mechanism.
Description check ✅ Passed The description comprehensively covers the changeset, including implementation details, testing approach, and notes on backward compatibility.
Linked Issues check ✅ Passed All objectives from issue #9 are met: RPC validation, health checks, fallback logic, local-node support, diagnostic panel, and backward compatibility [#9].
Out of Scope Changes check ✅ Passed All changes align with issue #9 requirements: RPC validation, health probing, fallback endpoints, diagnostic panel, and supporting tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
tests/rpc-health.test.ts (1)

11-57: ⚡ Quick win

Add 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9af9d34 and ddcacc2.

📒 Files selected for processing (6)
  • src/App.tsx
  • src/components/rpc-diagnostics.tsx
  • src/constants/config.ts
  • src/utils/rpc-health.ts
  • tests/config.test.ts
  • tests/rpc-health.test.ts

Comment on lines +13 to +21
async function check() {
const [healthResult, url] = await Promise.all([
rpcHealthCheck(RPC_URL, 2000),
resolveRpcUrl(2000),
]);
if (!cancelled) {
setHealth(healthResult);
setResolvedUrl(url);
setLoading(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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);
       }
     }

Comment thread src/constants/config.ts
Comment on lines +13 to +42
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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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`;

Comment thread src/utils/rpc-health.ts
Comment on lines +40 to +71
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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);
}

Comment thread src/utils/rpc-health.ts
Comment on lines +65 to +68
return {
healthy: true,
chainId: data.result ? BigInt(data.result) : null,
latencyMs: Date.now() - start,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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,
};

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.

RPC Robustness & Fallback – Handoff

1 participant