Skip to content

Diagnose facilitator failures instead of quoting them, and give redis real CI coverage - #714

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

Diagnose facilitator failures instead of quoting them, and give redis real CI coverage#714
MikeyPetrillo merged 7 commits into
mainfrom
claude/sweet-brown-i99jl3

Conversation

@MikeyPetrillo

Copy link
Copy Markdown
Owner

Today's settle failures logged 200 characters of <html><head><title>Coinbase</title> and nothing else, and I twice drew the wrong conclusion from them.

Diagnostics. @x402/core truncates an error body at 200 chars; on an HTML page that budget is gone before the first word. The wrapper reads the response before the vendor truncates, strips markup, and classifies: Cloudflare challenge/block, access denied, rate limited, origin failure behind the edge, or gateway timeout — keeping cf-ray, server and retry-after. It only touches non-2xx non-JSON responses from registered facilitator hosts, clones before reading (consuming the body would break settlement), swallows every internal error, and logs once at boot so a silent failure to install is visible immediately.

Labels. The failure hooks logged the chain and never the client, so Solana/Polygon/Arbitrum failures read as Coinbase's words though the boot log routes those to PayAI and only Base to CDP — clients are tried in order, so the surfacing error is the first tried, not the chain's owner. The label is prefixed, never substituted: isPreBroadcastSettleRejection matches settle failed (402) as a substring, so replacing the message would silently break the fallback's safety classification. Pinned by an assertion.

Redis CI. Nothing had ever connected to a redis: test-shared-limit.js injects a fake store by design, so the client path was untested and #708 (redis 4→6, two majors) had a meaningless green. Prod is not in-memory — REDIS_URL and RATE_LIMIT_REPLICAS are set on the production service. Now there's a redis:7-alpine service container and an integration test driving the real client (cap-of-1, over-limit decrement, refund flooring, cache round trip), asserting degraded === false so it can't pass via the fail-closed path. It exits 1 rather than skipping without a server.

Three of my own bugs were caught by these tests rather than by review: the classifier searched header names so every page read as a Cloudflare block; the watched-host set lived inside the wrapper so only the first facilitator was ever covered; and I asserted on a peek().used field that does not exist.

MikeyPetrillo and others added 7 commits August 7, 2026 09:30
Settlement failed 15 times in eleven minutes today across Base, Solana,
Polygon and Arbitrum, and every log line said the same thing:

  Facilitator settle failed (502): <html> <head> <title>Coinbase</title>
  <meta name="robots" content="noindex"> <meta property="viewport" ...

That is 200 characters of boilerplate and no diagnosis. @x402/core truncates
an error body at 200 chars, and on an HTML page the budget is gone before the
first word of the message. So "their origin fell over" and "an edge is
refusing our egress IP" were indistinguishable - and they demand opposite
responses: wait, or build a relay.

That distinction is not hypothetical here. Four relays already exist (Yahoo,
Nasdaq, Sei, Nodely) because third parties block Railway's egress IPs; Nodely
403s it outright. And api.cdp.coinbase.com sits behind Cloudflare, which is
what serves an HTML page where an API would return JSON. I guessed upstream
outage from those 200 characters and had no business doing so.

Same defect payments.js already documents for network-level failures, one
layer up: a cause we discard is not a cause we do not have. That fix read
err.cause; this one reads the response before the vendor truncates it.

The wrapper only touches non-2xx, non-JSON responses from registered
facilitator hosts. It CLONES before reading - consuming the caller's body
would break settlement outright - and swallows every internal failure, because
a diagnostic that can break the thing it describes is worse than the blindness
it cures. It logs once at boot so a silent failure to install is visible now
rather than during the next incident.

Two of my own bugs, both caught by the tests rather than by review:

- The classifier folded header NAMES into the text it searched, so the literal
  string "cf-mitigated:" appeared on every response and a plain origin 502 was
  reported as Cloudflare blocking us - precisely the confusion this exists to
  end. Values only now.
- Hosts lived inside the wrapper, so with facilitators registering one at a
  time only the first was ever watched: the wrapper would exist and cover
  nothing. They live outside it, and an assertion proves a host registered
  after installation is still watched.

Verified live rather than assumed: booted the paid path and confirmed the
install line appears with a real facilitator registered, so client.url
extraction works and this is not dead code. 24 offline assertions in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The failure hooks log the CHAIN and never the client, and that is why today's
diagnosis went wrong twice. Settle failures on Solana, Polygon and Arbitrum all
came back in Coinbase's words - but the boot log routes those three to PayAI
and only Base to CDP:

  Multi-chain facilitator routing: CDP (Base + Bazaar) → PayAI (remaining chains)

Clients are tried in order, so the error that surfaces is the FIRST one tried,
not the one that owns the chain. "PayAI rejected this" and "CDP was tried first
and never got past the edge" are different failures with different fixes, and
the log could not tell them apart. I read those lines and concluded a CDP
outage, which the evidence never supported.

The label is PREFIXED onto the message and attached as a property, never
substituted. isPreBroadcastSettleRejection matches `settle failed (402)` as a
substring and the summarizer scans the message for the facilitator's JSON body,
so replacing it would have silently broken the fallback's safety
classification - a logging change quietly costing revenue. There is an
assertion for exactly that: a prefixed 402 must still classify as a
safe-to-retry pre-broadcast rejection.

First label wins, so an inner client that already named itself is not
overwritten by an outer one, and double-wrapping cannot double-prefix. An
unwritable error is rethrown unchanged - a label is a nicety and must never
turn a facilitator error into a different failure.

Verified by boot on the paid path, and the seven payment suites
(supported-guard, stellar-confirm, svm-clarify, price-premium, mpp-shim,
payer-header-order, payment-verify) all pass unchanged. 30 offline assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cted to one

Dependabot's redis 4 -> 6 (#708) is two majors on the library behind
src/cache.js and src/shared-limit.js, and its CI was green. That green was
worth nothing: no workflow ran a redis, and test-shared-limit.js injects a fake
store on purpose - it proves "two callers share one counter", and a fake proves
that exactly - so the CLIENT path was never exercised. createClient options,
connect, command shapes, quit: all untested. Same worthless green as the
tesseract 5 -> 7 trap, which passed 210 assertions while silently emptying a
paid tool's output.

It matters because prod is NOT in-memory. Verified against Railway rather than
against notes: REDIS_URL and RATE_LIMIT_REPLICAS are both set on the production
service and the project runs a dedicated Redis. The shared limiter FAILS
CLOSED, so a client regression refuses trials rather than degrading quietly.
The memory note claiming "prod is in-memory, low risk, take anytime" was wrong
in the reassuring direction and would have merged the bump.

So: a redis service container on the test job, and an integration test that
drives the real client - cap-of-1 across callers, the over-limit decrement,
refund flooring, and a cache set/get round trip. It asserts degraded === false,
because the fail-closed path would otherwise let every assertion pass for the
wrong reason with no server at all.

It REQUIRES a server and exits 1 without one. It does not skip: a skipped
integration test is precisely why this went untested, and "no redis in CI"
means the service container is gone, which is the regression this guards.

Caught while writing it: peek() returns { limited, count, degraded }, not
`.used` as I had assumed - read the source instead of guessing, since a wrong
assertion here would have looked like a redis failure.

NOT verified locally (no redis or docker on this machine), so CI is the first
real run. Flagged rather than glossed.

This makes #708 decidable instead of a coin flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Their deploy was CANCELLED by the next push - deploy.yml uses
cancel-in-progress, so any push kills the run in flight, and cancelled is not
green. Production is on c0fbae2 (the diagnostics) without the labels. This
ships them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MikeyPetrillo
MikeyPetrillo merged commit 523a983 into main Aug 7, 2026
19 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