Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/test/fetch-402-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
46 changes: 46 additions & 0 deletions src/test/fetch-dry-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
78 changes: 60 additions & 18 deletions src/tools/lightning/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {
...(await readErrorBody(result)),
...(result.payment?.credentials
? paidRecoveryDetails(result.payment)
: undefined,
);
: {}),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const bytes = new Uint8Array(await result.arrayBuffer());
Expand Down Expand Up @@ -208,18 +208,58 @@ 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.
* 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.
*/
async function readErrorBody(response: Response): Promise<{
content?: string;
content_truncated?: boolean;
}> {
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: text.slice(0, MAX_ERROR_BODY_CHARS),
...(text.length > MAX_ERROR_BODY_CHARS
? { content_truncated: true }
: {}),
};
}

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"),
Expand All @@ -233,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;
}

/**
Expand All @@ -246,19 +286,21 @@ 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<DryRun402Result> {
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 ? {} : 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.
Expand Down
Loading