From 36ff6449da8facd80437e60f4792d3970402197c Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Fri, 24 Jul 2026 15:15:47 +0700 Subject: [PATCH 1/2] fix: surface non-OK response bodies instead of discarding them (#63) Co-Authored-By: Claude Fable 5 --- src/test/fetch-402-recovery.test.ts | 4 ++- src/test/fetch-dry-run.test.ts | 46 +++++++++++++++++++++++++++++ src/tools/lightning/fetch.ts | 38 ++++++++++++++++++++---- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/test/fetch-402-recovery.test.ts b/src/test/fetch-402-recovery.test.ts index 4063d21..0edc0e3 100644 --- a/src/test/fetch-402-recovery.test.ts +++ b/src/test/fetch-402-recovery.test.ts @@ -150,7 +150,9 @@ describe("fetch 402 payment recovery", () => { const error = await fetch402(client, { url: URL }).catch((e) => e); expect(error.message).toContain("non-OK status: 404"); - expect(error.details).toBeUndefined(); + // The error body is surfaced (it is often the only diagnostic), but there + // is no payment to recover. + expect(error.details).toEqual({ content: "not found" }); expect(client.payInvoice).not.toHaveBeenCalled(); }); diff --git a/src/test/fetch-dry-run.test.ts b/src/test/fetch-dry-run.test.ts index be9de93..118876c 100644 --- a/src/test/fetch-dry-run.test.ts +++ b/src/test/fetch-dry-run.test.ts @@ -98,5 +98,51 @@ describe("fetch --dry-run price preview", () => { expect(result.payment_required).toBe(false); expect(result.status).toBe(200); + // A 2xx dry run is about the price, not the content. + expect(result.content).toBeUndefined(); + }); + + test("surfaces the error body of a non-2xx response", async () => { + const body = JSON.stringify({ + error: "No payable option", + reason: "upstream price exceeds this gateway's per-request cap", + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(body, { status: 502 })), + ); + + const result = await dryRun402({ url: "https://bridge.example/api" }); + + expect(result.payment_required).toBe(false); + expect(result.status).toBe(502); + expect(result.content).toBe(body); + expect(result.content_truncated).toBeUndefined(); + }); + + test("truncates an oversized error body and says so", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue(new Response("x".repeat(5000), { status: 500 })), + ); + + const result = await dryRun402({ url: "https://big.example/api" }); + + expect(result.content).toHaveLength(4096); + expect(result.content_truncated).toBe(true); + }); + + test("omits content for an empty error body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + ); + + const result = await dryRun402({ url: "https://empty.example/api" }); + + expect(result.status).toBe(404); + expect(result.content).toBeUndefined(); }); }); diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index c3de391..5389576 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -116,12 +116,12 @@ export async function fetch402( // A non-OK response after a payment must not lose the payment metadata - // with the credential the request can be retried without paying the same // invoice again. - throw new DetailedError( - `fetch returned non-OK status: ${result.status} ${await result.text()}`, - result.payment?.credentials + throw new DetailedError(`fetch returned non-OK status: ${result.status}`, { + ...errorBody(await result.text()), + ...(result.payment?.credentials ? paidRecoveryDetails(result.payment) - : undefined, - ); + : {}), + }); } const bytes = new Uint8Array(await result.arrayBuffer()); @@ -208,6 +208,29 @@ export interface DryRun402Result { description?: string | null; /** Present when the challenge offers no lightning invoice. */ message?: string; + /** Body of a non-2xx, non-402 response - often the only diagnostic. */ + content?: string; + /** Set when `content` was cut off at the size cap. */ + content_truncated?: boolean; +} + +const MAX_ERROR_BODY_CHARS = 4096; + +/** + * An error body is often the only diagnostic (e.g. a gateway explaining + * exactly why it refused) - surface it, capped, instead of discarding it. + */ +function errorBody(bodyText: string): { + content?: string; + content_truncated?: boolean; +} { + if (!bodyText) return {}; + return { + content: bodyText.slice(0, MAX_ERROR_BODY_CHARS), + ...(bodyText.length > MAX_ERROR_BODY_CHARS + ? { content_truncated: true } + : {}), + }; } const BOLT11_PATTERN = /ln(?:bc|tb|bcrt|tbs)[0-9a-z]+/i; @@ -246,7 +269,9 @@ function extractLightningInvoice( * or dynamic - the challenge is the authoritative price at request time. Needs * no wallet. */ -export async function dryRun402(params: Fetch402Params) { +export async function dryRun402( + params: Fetch402Params, +): Promise { const response = await fetch(params.url, buildRequestOptions(params)); const bodyText = await response.text(); @@ -255,6 +280,7 @@ export async function dryRun402(params: Fetch402Params) { url: params.url, status: response.status, payment_required: false, + ...(response.ok ? {} : errorBody(bodyText)), } satisfies DryRun402Result; } From 47e72a3d0291cebfd8cddf7beaa0b6a463dba032 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 25 Jul 2026 15:51:20 +0700 Subject: [PATCH 2/2] fix: bound error-body reads at the cap and take invoices only from 402 headers Co-Authored-By: Claude Fable 5 --- src/tools/lightning/fetch.ts | 54 +++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index 5389576..f7b5399 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -117,7 +117,7 @@ export async function fetch402( // with the credential the request can be retried without paying the same // invoice again. throw new DetailedError(`fetch returned non-OK status: ${result.status}`, { - ...errorBody(await result.text()), + ...(await readErrorBody(result)), ...(result.payment?.credentials ? paidRecoveryDetails(result.payment) : {}), @@ -219,15 +219,34 @@ const MAX_ERROR_BODY_CHARS = 4096; /** * An error body is often the only diagnostic (e.g. a gateway explaining * exactly why it refused) - surface it, capped, instead of discarding it. + * The body is read through the stream and stops at the cap, so an + * arbitrarily large error body is never buffered whole. A failed read + * surfaces what was read so far rather than throwing, so the payment + * metadata alongside it is never lost. */ -function errorBody(bodyText: string): { +async function readErrorBody(response: Response): Promise<{ content?: string; content_truncated?: boolean; -} { - if (!bodyText) return {}; +}> { + if (!response.body) return {}; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + try { + while (text.length <= MAX_ERROR_BODY_CHARS) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } catch { + } finally { + reader.cancel().catch(() => {}); + } + if (!text) return {}; return { - content: bodyText.slice(0, MAX_ERROR_BODY_CHARS), - ...(bodyText.length > MAX_ERROR_BODY_CHARS + content: text.slice(0, MAX_ERROR_BODY_CHARS), + ...(text.length > MAX_ERROR_BODY_CHARS ? { content_truncated: true } : {}), }; @@ -235,14 +254,12 @@ function errorBody(bodyText: string): { const BOLT11_PATTERN = /ln(?:bc|tb|bcrt|tbs)[0-9a-z]+/i; -// Find the lightning invoice a 402 challenge offers, wherever the protocol -// puts it: L402 and MPP carry it in WWW-Authenticate (invoice="lnbc..."); -// lightning-native x402 embeds it in the base64 Payment-Required header (or -// the JSON body) as extra.invoice. -function extractLightningInvoice( - response: Response, - bodyText: string, -): string | null { +// Find the lightning invoice a 402 challenge offers. Only the headers are +// checked, matching the pay path in lightning-tools (which never reads the +// 402 body): L402 and MPP carry the invoice in WWW-Authenticate +// (invoice="lnbc..."); lightning-native x402 embeds it in the base64 +// Payment-Required header as extra.invoice. +function extractLightningInvoice(response: Response): string | null { const wwwAuthenticate = response.headers.get("www-authenticate") ?? ""; const headerMatch = wwwAuthenticate.match( new RegExp(`invoice="(${BOLT11_PATTERN.source})"`, "i"), @@ -256,11 +273,11 @@ function extractLightningInvoice( const match = decoded.match(BOLT11_PATTERN); if (match) return match[0]; } catch { - // Not base64 - fall through to the body. + // Not base64 - no usable invoice. } } - return bodyText.match(BOLT11_PATTERN)?.[0] ?? null; + return null; } /** @@ -273,18 +290,17 @@ export async function dryRun402( params: Fetch402Params, ): Promise { const response = await fetch(params.url, buildRequestOptions(params)); - const bodyText = await response.text(); if (response.status !== 402) { return { url: params.url, status: response.status, payment_required: false, - ...(response.ok ? {} : errorBody(bodyText)), + ...(response.ok ? {} : await readErrorBody(response)), } satisfies DryRun402Result; } - const invoice = extractLightningInvoice(response, bodyText); + const invoice = extractLightningInvoice(response); if (invoice) { // The pattern can match invoice-looking garbage; a challenge whose // "invoice" doesn't decode offers no usable invoice - fall through.