Skip to content

l402: tell the truth when the gateway cannot issue an invoice - #323

Open
ryanthegentry wants to merge 2 commits into
masterfrom
l402-honest-degradation
Open

l402: tell the truth when the gateway cannot issue an invoice#323
ryanthegentry wants to merge 2 commits into
masterfrom
l402-honest-degradation

Conversation

@ryanthegentry

Copy link
Copy Markdown
Owner

What happened

Boltz disabled swap creation at 2026-08-03T08:52:56Z. Every challenge attempt failed for the rest of the day. The index handled that in two ways that were each worse than saying nothing, and they chained.

export.csv returned a bare 402 whose body advised adding ?l402=require. 402explorer/0.1 followed that advice — every one of its five hits that day is trailed ~3s later by a ?l402=require request. That parameter sets the free limit to 0 on purpose so a challenge gets minted; the challenge path then failed and fell through to 429 Rate limit exceeded. Try again later. with Retry-After: 60.

A well-behaved client asking to pay was told it had been rate-limited and should retry in a minute, for an upstream outage no retry would ever clear — once every ~70 minutes for five hours.

What changed

Honest degradation. Both call sites (export.csv route, sendL402Challenge) now return 503 with reason: "l402_gateway_unavailable", a message naming the cause, and a Retry-After that does not imply near-term recovery.

The two "no challenge" branches are split apart, because they were never the same thing:

  • thrown error → outage → 503
  • null challenge → no gateway configured at all → still 402, but no longer pointing at ?l402=require. The same absent gateway serves both, so the old text just bounced callers between two dead ends.

The paid path is asserted unchanged: a working gateway still returns 402 + WWW-Authenticate + invoice on both routes.

Payment observability. The index has no macaroon, token or settlement table, so it could not answer "has anyone ever paid?". That gap is why a user-agent slipping below a 7d top-15 traffic cutoff got reported as a lost paying buyer, when the gateway's own counters said: challenges issued, none paid, nothing earned. The digest now carries a payments block read from the gateway. It reports nulls plus a reason when unreadable — never zeroes, which would read as "we know, and the answer is none" — and never fails the digest, since an unreachable gateway is exactly when the digest matters most.

Note for deploy

The Atlas watchdog's HEAD /api/v1/export.csv will now see 503 instead of 402 while the gateway is down. That is intended — it should notice — but it is a new alert surface.

Tests

Root 2015 pass / 0 fail / 5 skip · router 135 / 0 / 2 skip · mcp-server 87 / 0. Lint clean.

New: test/l402-degraded-path.test.js (9), test/digest-payments.test.js (6). Both written RED first against a real ephemeral server and a stub gateway returning the 500 the partner gateway actually returned during the outage.

🤖 Generated with Claude Code

ryanthegentry and others added 2 commits August 3, 2026 09:27
Boltz disabled swap creation at 2026-08-03T08:52:56Z. For the rest of the day
every challenge attempt failed, and the index answered the one agent engaging
with its paywall in two ways that were each worse than saying nothing.

export.csv returned a bare 402 whose body advised adding `?l402=require`.
402explorer/0.1 followed that advice — every one of its five hits is trailed
about three seconds later by a `?l402=require` request. That parameter sets the
free limit to 0 on purpose, so the challenge path ran, failed, and fell through
to `429 Rate limit exceeded. Try again later.` with Retry-After: 60.

So a well-behaved client asking to pay was told it had been rate-limited and
should retry in a minute, for an upstream outage that no retry would ever clear,
once every seventy minutes for five hours.

Both call sites now return 503 with reason `l402_gateway_unavailable`, a message
that names the cause, and a Retry-After that does not imply near-term recovery.
The two "no challenge" branches are split apart: a thrown error is an outage and
gets the 503, while a null challenge means no gateway is configured at all, which
stays a 402 but no longer points at `?l402=require` — the same absent gateway
serves both, so the old text just bounced callers between two dead ends.

Also adds a payments block to the digest, sourced from the gateway's own
counters. The index keeps no macaroon, token or settlement table, so it could not
answer "has anyone ever paid?" — which is how a user-agent slipping below a
top-15 traffic cutoff got reported as a lost paying buyer when the gateway's
counters said challenges issued, none paid, nothing earned. The block reports
nulls plus a reason when the gateway is unreadable, never zeroes, and never
fails the digest: an unreachable gateway is exactly when the digest matters most.

Tests: 2015 pass / 5 skip root, 135 pass / 2 skip router, 87 mcp-server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Boltz said today the shutdown is indefinite — no ETA, "do not expect swap
services to resume shortly", and they are openly unsure swaps resume at all.
The reason is not a bug: months of rising automated probing, several contained
exploits, and attackers iterating faster than they can patch.

Against that, the Retry-After of 300 this shipped with was a smaller version of
the lie it was written to remove. Five minutes tells a caller the problem is
nearly over when nobody knows that. Default is now 3600 and reads from
L402_UNAVAILABLE_RETRY_AFTER_SECONDS, so an operator can raise it without a
deploy.

The message drops "outage" for "upstream failure with no estimated restoration
time", and a test now forbids "temporarily", "shortly", "soon" and "brief" in
that body. We do not know when this comes back. Saying otherwise is the whole
failure mode.

Tests: 2023 pass / 5 skip. Lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ryanthegentry

Copy link
Copy Markdown
Owner Author

Update — the shutdown is indefinite, so the 503 needed resizing

Boltz, 2026-08-05: swaps stay disabled until further notice. Months of rising automated, AI-assisted probing, several contained exploits, "attackers now iterate faster than a team our size can find and patch." No ETA. "Do not expect swap services to resume shortly."

What that changes here

The 503 and the machine-readable reason are more valuable now, not less — this is no longer a bad afternoon, it is the state of the paywall until a different rail exists.

But Retry-After: 300 had become a smaller version of the lie this PR was written to remove. Five minutes tells a caller the problem is nearly over when nobody knows that, including Boltz. Default is now 3600, read from L402_UNAVAILABLE_RETRY_AFTER_SECONDS so an operator can raise it without a deploy.

The message drops "outage" — which implies something that passes — for "upstream failure with no estimated restoration time". A test now forbids temporarily, shortly, soon and brief in that body.

The digest payments block is the part that matters most now

It was written so revenue claims could cite the gateway instead of traffic rank. Its job is bigger today: it is how anyone will know whether a replacement rail is earning anything, against a baseline that is unambiguous — 77 challenges issued, 0 paid, 0 sats, ever.

Tests: 2023 pass / 0 fail / 5 skip. Lint clean.

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