@@ -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
213255const BOLT11_PATTERN = / l n (?: b c | t b | b c r t | t b s ) [ 0 - 9 a - 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