Skip to content

diagnostics: make network failures, degraded rails and metered spend all name themselves - #656

Merged
MikeyPetrillo merged 4 commits into
mainfrom
claude/sweet-brown-i99jl3
Aug 2, 2026
Merged

diagnostics: make network failures, degraded rails and metered spend all name themselves#656
MikeyPetrillo merged 4 commits into
mainfrom
claude/sweet-brown-i99jl3

Conversation

@MikeyPetrillo

Copy link
Copy Markdown
Owner

Four commits from an investigation that started with "why does the revenue page look short" and ended in the metered-upstream billing.

a865839 — rpcCall names WHY each lane failed

Naming the lane was half a fix. Node flattens every network-level failure to the bare string "fetch failed" and hangs the reason on err.cause, so relay: fetch failed | publicnode: fetch failed still cannot separate DNS from a refused connection from an unroutable IPv6 address — three different fixes. That indistinguishability is exactly what gets written off as "transient".

Now: fetch failed [ECONNREFUSED syscall=connect address=1.2.3.4 port=443]. Dual-stack hosts (most of these RPCs sit behind Cloudflare with A and AAAA) fail via AggregateError whose wrapper carries no code at all, so the per-address branch is what makes "IPv6 is unroutable from here" distinguishable from "the host is down". describeError() is exported because this flattening exists in ~45 other places.

60ef3d1 — the heartbeat tells someone when a rail goes down

Celo was dropped for days and surfaced only because someone asked. Every component worked as designed: the rail was dropped so others kept earning, revenue flowed, /health stayed 200, and the only trace was one boot log. Survivable had quietly become invisible.

The heartbeat now opens one tracked issue naming each configured-but-unoffered rail and its reason, and closes it when they return. Deliberately an issue, not a page — a degraded rail is not an outage.

Guarded explicitly: an unreadable /api/rails is NOT treated as healthy. A failed curl would otherwise look identical to degraded=0 and close a real issue mid-outage, which is how the charged-failure alarm went months without firing.

cb4e5ff — stop re-billing a metered upstream that is already broken

Keyed self-check successes cached for 6h; failures deliberately did not, so a real key problem re-tested every poll. With a 30-minute poll and a retry that is ~96 billed Brave requests a day — the failure mode bills hardest exactly when the thing is already broken.

Failures now cache 30 min. The property asserted: caching a failure cannot hide it. The result is still ok:false, stays in failing, and tool-alert.yml still pages. Only the paying-to-re-confirm changes.

Tests

test-revenue-scan 21→26, test-rail-selfheal 13→20, test-selfcheck 10→14, all mutation-checked. Full battery green: 210 strict / 296 lenient, test-brave-leak 27, test-rails 114.

Same defect as the facilitator path, in the busier place. Naming the lane was
only half the fix: Node flattens every network-level failure to the bare string
"fetch failed", so

  all 2 RPCs failed - relay.example: fetch failed | publicnode: fetch failed

is still unactionable. It cannot separate a DNS failure from a refused
connection from an unroutable IPv6 address, and those are three different fixes
(bad hostname / dead host / needs a relay). That indistinguishability is
precisely what gets written off as a transient blip.

Now:

  all 2 RPCs failed - nonexistent.invalid: fetch failed [ENOTFOUND syscall=getaddrinfo]
    | 127.0.0.1:45999: fetch failed [ECONNREFUSED syscall=connect address=127.0.0.1 port=45999]

The dual-stack branch matters most here. Most of these RPCs sit behind
Cloudflare with both A and AAAA records, so Happy Eyeballs failing every address
returns an AggregateError whose WRAPPER carries no code at all - the per-address
reasons live in `.errors`. Without that branch a dual-stack failure still logs
bare "fetch failed"; with it the log says which family failed and at which
address, which is the difference between "unknowable" and "our egress cannot
reach Cloudflare over v6, add a relay like Sei and Algorand already needed".

describeError() is exported and tested rather than inlined, because this exact
flattening exists in ~45 other places and the next one should reuse it instead
of rediscovering the problem.

An error with no cause is returned unchanged - asserted, so this cannot add
noise to the ordinary case.

scripts/test-revenue-scan.js 21 -> 26, mutation-checked: dropping the cause
collapses the output back to bare "fetch failed".
… of only surviving it

Celo was dropped from the offer for DAYS and the only reason anyone found out
is that you asked why the revenue page looked short.

Every piece of that was working as designed, which is the problem. payments.js
drops a rail nobody can settle so the other chains keep earning. Paid revenue
kept flowing. /health stayed 200. The single trace was one boot log line. The
system survived it perfectly and told nobody, and "survivable" quietly became
"invisible".

The heartbeat now reads /api/rails every cycle and opens ONE tracked issue
naming each configured-but-unoffered rail and its reason. It closes itself when
every rail is offered again, which now happens on its own: rail-selfheal.yml
restarts the service once the rail's own facilitator answers, and the next
heartbeat closes the issue.

Deliberately an ISSUE, not a page. A degraded rail is not an outage - the other
eleven chains are still settling - so it sits at the same severity as
"burner LOW": visible, tracked, auto-closing, nobody woken at 3am for someone
else's facilitator.

The trap this class of check keeps falling into, guarded explicitly: an
unreadable /api/rails is NOT treated as healthy. A failed curl would otherwise
look identical to "degraded=0" and CLOSE a real issue mid-outage - which is
exactly how the charged-failure alarm managed to never fire once in months.

Dry-run against live production: degraded=1, celo named with its reason, and
the recovered and unreadable branches both behave correctly.

scripts/test-rail-selfheal.js 13 -> 20, mutation-checked: making an unreadable
response count as healthy fails the guard.

One test bug caught while writing it: the "must not page" assertion split the
file on the step name and read everything after, which swallowed later steps
that legitimately page on production being down. It failed for the wrong reason
until scoped to the step itself.
…already known broken

Found while auditing why Brave billed ~56 requests against 6 sold search calls
this month.

Keyed self-check successes were cached for 6h, but FAILURES were deliberately
not cached so a real key problem would re-test on the next poll. Right instinct,
missing bound: /api/selfcheck is polled every 30 minutes and a failing check
retries once, so a broken Brave key or a Brave outage costs ~96 billed requests
a day. The failure mode bills hardest exactly when the thing is already broken -
the outage pays for itself twice.

Failures are now cached for 30 minutes (successes keep the 6h TTL). The
property that must not change, and is asserted: caching a failure CANNOT hide
it. The cached result is still ok:false, so it stays in `failing`, the endpoint
still reports it, and tool-alert.yml still pages and keeps its issue open. The
only thing that changes is how often we PAY to re-confirm something we already
know.

Measured on a stub: an always-failing metered tool now costs 2 upstream calls
(attempt + retry) and then nothing until the TTL expires, instead of 2 per poll
forever. The success path is untouched - still one call, then cached 6h.

scripts/test-selfcheck.js +4, mutation-checked: un-caching negatives takes the
stub from 2 upstream calls to 6 across three polls and fails the guard.

NOT the whole story on the Brave spend, and worth recording honestly: the
biggest single contributor this month was DEPLOY FREQUENCY. The keyed cache is
in-memory, so every restart clears it and the next poll re-checks; there have
been 13 deploys since Aug 1. That is a cost of shipping often, not a defect, and
it is not something to "fix" by deploying less.
Two fixes and one dismissal, from the "why is Brave billing more than search
earns" thread.

1. multi-search deduped. The price is flat for 2-5 queries but EVERY query was
   a separate billed Brave request, so ["x","x","x","x","x"] cost five upstream
   calls for one $0.08 sale. A margin leak on honest duplicates and a free 5x
   multiplier for anyone who noticed. Fan-out is now over unique queries; the
   response is still built per INPUT query so the caller's shape and ordering
   are byte-identical. Six inputs with two distinct queries: 5 billed calls
   before, 2 after.

2. /__operator/ledger-sync.json cached for 15s (CodeQL js/missing-rate-limiting,
   alert #81 - a fair hit on code I added last week). Each request fired one
   live eth_blockNumber PER CHAIN, so holding refresh hammered a dozen shared
   public RPCs. Operator auth bounds WHO can do that, not how often. Cursor
   positions do not move faster than the window, so the cache costs no accuracy.

3. CodeQL #80 dismissed as a false positive with evidence: rate limiting IS
   present on that path, CodeQL just cannot trace it through the middleware -
   powHttpLimiter.check(req.ip) on PoW redemption and trialToolLimiter/
   trialIpLimiter on the trial path, both the shared limiter in rate-limit.js.

On multi-search PRICING, which is what was actually asked: I am not changing it.
At a measured ~$0.007/Brave request the tool runs 56% margin at five queries and
82% at two, so it was never underwater. The month's shortfall was unpaid calls,
not underpricing, and the dedupe removes the worst of what remained. Repricing a
profitable tool to fix a spend leak would have been the wrong lever.

scripts/test-brave-leak.js 27 -> 32.
@MikeyPetrillo
MikeyPetrillo temporarily deployed to agent402 / production August 2, 2026 16:14 — with GitHub Actions Inactive
@railway-app
railway-app Bot temporarily deployed to agent402 / production August 2, 2026 16:14 Inactive
@MikeyPetrillo
MikeyPetrillo marked this pull request as ready for review August 2, 2026 16:18
@MikeyPetrillo
MikeyPetrillo merged commit 8e2cdd6 into main Aug 2, 2026
28 checks passed
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