Skip to content

Commit ae789db

Browse files
rolznzclaude
andauthored
fix: surface non-OK response bodies instead of discarding them (#64)
* fix: surface non-OK response bodies instead of discarding them (#63) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: bound error-body reads at the cap and take invoices only from 402 headers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ce434db commit ae789db

3 files changed

Lines changed: 109 additions & 19 deletions

File tree

src/test/fetch-402-recovery.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ describe("fetch 402 payment recovery", () => {
150150
const error = await fetch402(client, { url: URL }).catch((e) => e);
151151

152152
expect(error.message).toContain("non-OK status: 404");
153-
expect(error.details).toBeUndefined();
153+
// The error body is surfaced (it is often the only diagnostic), but there
154+
// is no payment to recover.
155+
expect(error.details).toEqual({ content: "not found" });
154156
expect(client.payInvoice).not.toHaveBeenCalled();
155157
});
156158

src/test/fetch-dry-run.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,51 @@ describe("fetch --dry-run price preview", () => {
9898

9999
expect(result.payment_required).toBe(false);
100100
expect(result.status).toBe(200);
101+
// A 2xx dry run is about the price, not the content.
102+
expect(result.content).toBeUndefined();
103+
});
104+
105+
test("surfaces the error body of a non-2xx response", async () => {
106+
const body = JSON.stringify({
107+
error: "No payable option",
108+
reason: "upstream price exceeds this gateway's per-request cap",
109+
});
110+
vi.stubGlobal(
111+
"fetch",
112+
vi.fn().mockResolvedValue(new Response(body, { status: 502 })),
113+
);
114+
115+
const result = await dryRun402({ url: "https://bridge.example/api" });
116+
117+
expect(result.payment_required).toBe(false);
118+
expect(result.status).toBe(502);
119+
expect(result.content).toBe(body);
120+
expect(result.content_truncated).toBeUndefined();
121+
});
122+
123+
test("truncates an oversized error body and says so", async () => {
124+
vi.stubGlobal(
125+
"fetch",
126+
vi
127+
.fn()
128+
.mockResolvedValue(new Response("x".repeat(5000), { status: 500 })),
129+
);
130+
131+
const result = await dryRun402({ url: "https://big.example/api" });
132+
133+
expect(result.content).toHaveLength(4096);
134+
expect(result.content_truncated).toBe(true);
135+
});
136+
137+
test("omits content for an empty error body", async () => {
138+
vi.stubGlobal(
139+
"fetch",
140+
vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
141+
);
142+
143+
const result = await dryRun402({ url: "https://empty.example/api" });
144+
145+
expect(result.status).toBe(404);
146+
expect(result.content).toBeUndefined();
101147
});
102148
});

src/tools/lightning/fetch.ts

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,12 @@ export async function fetch402(
116116
// A non-OK response after a payment must not lose the payment metadata -
117117
// with the credential the request can be retried without paying the same
118118
// invoice again.
119-
throw new DetailedError(
120-
`fetch returned non-OK status: ${result.status} ${await result.text()}`,
121-
result.payment?.credentials
119+
throw new DetailedError(`fetch returned non-OK status: ${result.status}`, {
120+
...(await readErrorBody(result)),
121+
...(result.payment?.credentials
122122
? paidRecoveryDetails(result.payment)
123-
: undefined,
124-
);
123+
: {}),
124+
});
125125
}
126126

127127
const bytes = new Uint8Array(await result.arrayBuffer());
@@ -208,18 +208,58 @@ export interface DryRun402Result {
208208
description?: string | null;
209209
/** Present when the challenge offers no lightning invoice. */
210210
message?: string;
211+
/** Body of a non-2xx, non-402 response - often the only diagnostic. */
212+
content?: string;
213+
/** Set when `content` was cut off at the size cap. */
214+
content_truncated?: boolean;
215+
}
216+
217+
const MAX_ERROR_BODY_CHARS = 4096;
218+
219+
/**
220+
* An error body is often the only diagnostic (e.g. a gateway explaining
221+
* exactly why it refused) - surface it, capped, instead of discarding it.
222+
* The body is read through the stream and stops at the cap, so an
223+
* arbitrarily large error body is never buffered whole. A failed read
224+
* surfaces what was read so far rather than throwing, so the payment
225+
* metadata alongside it is never lost.
226+
*/
227+
async function readErrorBody(response: Response): Promise<{
228+
content?: string;
229+
content_truncated?: boolean;
230+
}> {
231+
if (!response.body) return {};
232+
const reader = response.body.getReader();
233+
const decoder = new TextDecoder();
234+
let text = "";
235+
try {
236+
while (text.length <= MAX_ERROR_BODY_CHARS) {
237+
const { done, value } = await reader.read();
238+
if (done) break;
239+
text += decoder.decode(value, { stream: true });
240+
}
241+
text += decoder.decode();
242+
} catch {
243+
} finally {
244+
reader.cancel().catch(() => {});
245+
}
246+
if (!text) return {};
247+
return {
248+
content: text.slice(0, MAX_ERROR_BODY_CHARS),
249+
...(text.length > MAX_ERROR_BODY_CHARS
250+
? { content_truncated: true }
251+
: {}),
252+
};
211253
}
212254

213255
const BOLT11_PATTERN = /ln(?:bc|tb|bcrt|tbs)[0-9a-z]+/i;
214256

215-
// Find the lightning invoice a 402 challenge offers, wherever the protocol
216-
// puts it: L402 and MPP carry it in WWW-Authenticate (invoice="lnbc...");
217-
// lightning-native x402 embeds it in the base64 Payment-Required header (or
218-
// the JSON body) as extra.invoice.
219-
function extractLightningInvoice(
220-
response: Response,
221-
bodyText: string,
222-
): string | null {
257+
// Find the lightning invoice a 402 challenge offers. Only the headers are
258+
// checked, matching the pay path in lightning-tools (which never reads the
259+
// 402 body): L402 and MPP carry the invoice in WWW-Authenticate
260+
// (invoice="lnbc..."); lightning-native x402 embeds it in the base64
261+
// Payment-Required header as extra.invoice.
262+
function extractLightningInvoice(response: Response): string | null {
223263
const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
224264
const headerMatch = wwwAuthenticate.match(
225265
new RegExp(`invoice="(${BOLT11_PATTERN.source})"`, "i"),
@@ -233,11 +273,11 @@ function extractLightningInvoice(
233273
const match = decoded.match(BOLT11_PATTERN);
234274
if (match) return match[0];
235275
} catch {
236-
// Not base64 - fall through to the body.
276+
// Not base64 - no usable invoice.
237277
}
238278
}
239279

240-
return bodyText.match(BOLT11_PATTERN)?.[0] ?? null;
280+
return null;
241281
}
242282

243283
/**
@@ -246,19 +286,21 @@ function extractLightningInvoice(
246286
* or dynamic - the challenge is the authoritative price at request time. Needs
247287
* no wallet.
248288
*/
249-
export async function dryRun402(params: Fetch402Params) {
289+
export async function dryRun402(
290+
params: Fetch402Params,
291+
): Promise<DryRun402Result> {
250292
const response = await fetch(params.url, buildRequestOptions(params));
251-
const bodyText = await response.text();
252293

253294
if (response.status !== 402) {
254295
return {
255296
url: params.url,
256297
status: response.status,
257298
payment_required: false,
299+
...(response.ok ? {} : await readErrorBody(response)),
258300
} satisfies DryRun402Result;
259301
}
260302

261-
const invoice = extractLightningInvoice(response, bodyText);
303+
const invoice = extractLightningInvoice(response);
262304
if (invoice) {
263305
// The pattern can match invoice-looking garbage; a challenge whose
264306
// "invoice" doesn't decode offers no usable invoice - fall through.

0 commit comments

Comments
 (0)