Skip to content

fix(health): restore RPC pool eligibility once a live probe recovers#4958

Merged
JSONbored merged 1 commit into
mainfrom
fix/rpc-pool-eligibility-live-recovery
Jul 12, 2026
Merged

fix(health): restore RPC pool eligibility once a live probe recovers#4958
JSONbored merged 1 commit into
mainfrom
fix/rpc-pool-eligibility-live-recovery

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Investigated the RPC-usage anomaly a live-wiring verification sweep flagged: GET /api/v1/rpc/usage (30d window) showed a real per-endpoint breakdown across 6 finney RPC providers, but a direct POST /rpc/v1/finney call genuinely returned HTTP 503 {"error":{"code":"rpc_endpoint_unavailable","message":"No eligible public RPC endpoint is available for proxy routing."}}.

No external prober system exists for this — confirmed directly against Cloudflare (workers_list) and this repo: src/health-prober.mjs, invoked from workers/api.mjs's scheduled() handler on a 15-minute Cron Trigger, is the only thing that writes KV_HEALTH_RPC_POOL. Reading it end-to-end:

  • overlayRpcPoolEligibility (src/health-serving.mjs) merges that 15-minute live snapshot onto the once-daily static rpc/pools.json build (publish-cloudflare.yml, cron: "17 7 * * *").
  • It correctly refreshed the displayed status field from the live probe, but pool_eligible/pool_eligibility_reasons were only ever narrowed from the static build's boolean: Boolean(endpoint.pool_eligible) && !wrongChain && !sustainedDown. Once an endpoint was marked ineligible at build time, no live recovery could ever restore it before the next daily rebuild — up to ~24h.
  • Live-confirmed via curl https://api.metagraph.sh/api/v1/rpc/pools: all 4 finney-rpc endpoints were served with status:"ok" (health_source:"live-cron-prober", genuinely fresh) but pool_eligible:false / pool_eligibility_reasons:["status-degraded"] — self-contradictory served data — and orderSafeRpcEndpoints filters routing candidates on that same stale pool_eligible field, producing the real 503 outage.

Fix

Recompute pool_eligible/pool_eligibility_reasons on every overlay from the freshly-refreshed status, not the frozen static boolean.

Deliberately does not delegate to this file's existing endpointPoolEligibility helper (used correctly elsewhere by withPoolEligibility for /api/v1/endpoints) — that helper's status !== "ok" check has no hysteresis, which would regress the pre-existing sustained-down tolerance (a single transient non-ok probe must not evict an endpoint; only 2+ consecutive failures do, per this function's own header comment). Only the structural checks (kind/auth_required/public_safe — static properties that never change between prober runs) are reused; health eligibility is judged solely by wrong-chain / sustained-down, exactly as before, just no longer gated behind a boolean that could only ever get worse.

score/score_reasons are deliberately left static (documented in the code): a stale score never excludes an eligible endpoint, since weightedPickEndpoint falls back to weight 1 for score<=0 — it only deprioritises it in the failover order, a much lower-severity issue than the outright routing outage this fixes.

Test plan

  • Live-verified the fix directly: replayed the exact production finney-rpc static+live snapshot (status:"ok", pool_eligible:false, ["status-degraded"]) through the patched overlayRpcPoolEligibility in a real Node process against this repo's own module — now yields pool_eligible:true, pool_eligibility_reasons:["eligible"] instead of the frozen false.
  • Added a regression test reproducing this exact incident (tests/health-serving.test.mjs), plus tests for: hysteresis is preserved (a single non-ok probe below the sustained-down threshold keeps eligibility), structural policy (auth_required) still disqualifies regardless of live health, and reasons are re-derived (not just the boolean).
  • Updated existing test fixtures across tests/health-serving.test.mjs and tests/mcp-server.test.mjs to carry realistic kind/auth_required/public_safe fields — the prior minimal stubs (missing these) would now (correctly) fail structural eligibility under the fixed logic.
  • npm run lint / npm run format:check clean
  • npm test — 256/256 files, 7582/7582 tests green (full suite)
  • No contract files touched — validate:client-sdk-sync not applicable

overlayRpcPoolEligibility (src/health-serving.mjs) overlays the 15-minute
live-cron-prober's health onto the once-daily static rpc/pools.json
build. It correctly refreshed the displayed status field from the live
probe, but pool_eligible/pool_eligibility_reasons were only ever narrowed
from the static build's boolean -- `Boolean(endpoint.pool_eligible) &&
!wrongChain && !sustainedDown` -- so once an endpoint was marked
ineligible at build time, no live recovery could ever restore it before
the next daily rebuild (publish-cloudflare.yml, once/day).

This was live and actively causing an outage: investigated directly
against production Cloudflare/GitHub infrastructure (no external prober
system exists -- src/health-prober.mjs, on a 15-minute Cron Trigger via
workers/api.mjs's scheduled handler, is the only thing that writes
KV_HEALTH_RPC_POOL). All 4 finney-rpc endpoints were served with
status:"ok" (health_source:"live-cron-prober", genuinely fresh) but
pool_eligible:false / pool_eligibility_reasons:["status-degraded"] --
self-contradictory served data -- and POST /rpc/v1/finney genuinely
503'd with "No eligible public RPC endpoint is available for proxy
routing", because orderSafeRpcEndpoints filters candidates on this same
stale pool_eligible field.

Fix: recompute pool_eligible/pool_eligibility_reasons on every overlay
from the freshly-refreshed status, not the frozen static boolean.
Deliberately does NOT delegate to this file's existing
endpointPoolEligibility helper (used correctly elsewhere by
withPoolEligibility for /api/v1/endpoints) -- that helper's `status !==
"ok"` check has no hysteresis, which would regress the pre-existing
sustained-down tolerance (a single transient non-ok probe must not evict
an endpoint; only 2+ CONSECUTIVE failures do). Only the structural checks
(kind/auth_required/public_safe -- static properties that never change
between prober runs) are reused; health eligibility is judged solely by
wrong-chain/sustained-down, exactly as before, just no longer gated
behind a boolean that could only ever get worse.

score/score_reasons are left static (documented, out of scope): a stale
score never excludes an eligible endpoint since weightedPickEndpoint
falls back to weight 1 for score<=0, only deprioritises it in the
failover order.

Live-verified the fix directly: replaying the exact production
finney-rpc static+live snapshot through the patched
overlayRpcPoolEligibility now yields pool_eligible:true,
pool_eligibility_reasons:["eligible"] instead of the frozen false.
@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.94%. Comparing base (71814ee) to head (de61e13).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4958   +/-   ##
=======================================
  Coverage   97.94%   97.94%           
=======================================
  Files         163      163           
  Lines       19420    19431   +11     
  Branches     7376     7381    +5     
=======================================
+ Hits        19020    19031   +11     
  Misses         53       53           
  Partials      347      347           
Files with missing lines Coverage Δ
src/health-serving.mjs 91.63% <100.00%> (+0.16%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@JSONbored JSONbored merged commit 98dc924 into main Jul 12, 2026
13 checks passed
@JSONbored JSONbored deleted the fix/rpc-pool-eligibility-live-recovery branch July 12, 2026 14:56
@JSONbored JSONbored self-assigned this Jul 12, 2026
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