@@ -86,6 +86,13 @@ function assertUsableResponse(response: OpenAI.Responses.Response, providerLabel
8686 }
8787}
8888
89+ /**
90+ * Transport failures annotated once already. The error-body read is annotated where the
91+ * phase is known, then rethrown through an outer catch that would otherwise append a
92+ * second, wrong phase to the same message.
93+ */
94+ const annotatedTransportFailures = new WeakSet < Error > ( )
95+
8996type PreparedTools = ReturnType < typeof prepareToolsWithUsageControl >
9097type ToolChoice = PreparedTools [ 'toolChoice' ]
9198
@@ -287,17 +294,92 @@ export async function executeResponsesProviderRequest(
287294 ...overrides ,
288295 } )
289296
297+ /**
298+ * Names the request phase an opaque transport failure died in.
299+ *
300+ * Bun raises only `TimeoutError: The operation timed out.`, which cannot distinguish
301+ * "never answered" from "answered, but the body never arrived" — opposite owners,
302+ * opposite fixes. undici splits these as `UND_ERR_HEADERS_TIMEOUT` vs
303+ * `UND_ERR_BODY_TIMEOUT`; this records the equivalent for a runtime that reports
304+ * neither.
305+ *
306+ * The phase rides the error message because that reaches the block's trace span, which
307+ * survives when a task has stopped shipping logs; `x-request-id` is the only handle the
308+ * provider can trace the call by. Self-describing API errors are left untouched.
309+ */
310+ const annotateTransportFailure = (
311+ error : unknown ,
312+ phase : 'awaiting-response-headers' | 'reading-response-body' ,
313+ startedAt : number ,
314+ detail ?: Record < string , string | number | null >
315+ ) : unknown => {
316+ if ( ! ( error instanceof Error ) ) return error
317+ if ( error . name !== 'TimeoutError' && error . name !== 'AbortError' ) return error
318+ if ( annotatedTransportFailures . has ( error ) ) return error
319+
320+ const elapsedMs = Date . now ( ) - startedAt
321+ const fields = Object . entries ( detail ?? { } )
322+ . filter ( ( [ , value ] ) => value !== null && value !== undefined )
323+ . map ( ( [ key , value ] ) => `${ key } =${ value } ` )
324+ const context = [ `phase=${ phase } ` , `elapsedMs=${ elapsedMs } ` , ...fields ] . join ( ' ' )
325+
326+ logger . error ( `${ config . providerLabel } request failed in transport` , {
327+ phase,
328+ elapsedMs,
329+ errorName : error . name ,
330+ model : config . modelName ,
331+ workflowId : request . workflowId ,
332+ blockId : request . blockId ,
333+ executionId : request . executionId ,
334+ ...detail ,
335+ } )
336+
337+ /**
338+ * A new Error rather than a mutation: the runtime raises these as `DOMException`,
339+ * whose `message` is a readonly getter, so assigning to it throws a `TypeError` and
340+ * destroys the very failure being reported. `name` is copied and the original hangs
341+ * off `cause` so the classification survives the `ProviderError` wrapping below,
342+ * which overwrites `name`.
343+ */
344+ const annotated = new Error ( `${ error . message } [${ context } ]` , { cause : error } )
345+ annotated . name = error . name
346+ annotatedTransportFailures . add ( annotated )
347+ return annotated
348+ }
349+
350+ /**
351+ * The response-side facts worth carrying on a transport failure. `x-request-id` is the
352+ * only handle the provider can trace a failed call by.
353+ */
354+ const describeResponse = ( response : Response ) : Record < string , string | number | null > => ( {
355+ status : response . status ,
356+ requestId : response . headers . get ( 'x-request-id' ) ,
357+ contentLength : response . headers . get ( 'content-length' ) ,
358+ contentEncoding : response . headers . get ( 'content-encoding' ) ,
359+ } )
360+
290361 /**
291362 * A non-JSON body is usually a gateway or CDN error page and reaches the user-facing
292363 * block error, so it is bounded and falls back to `statusText`. A structured provider
293364 * message is returned untruncated on purpose: the reasoning-summary strip-and-retry
294365 * fallback matches on its text.
295366 *
296- * A failed body read is deliberately not caught: a deadline or a cancellation here must
297- * stay distinguishable from an error response that simply carried no body.
367+ * A failed body read is annotated rather than swallowed: a deadline or a cancellation
368+ * here must stay distinguishable from an error response that simply carried no body.
369+ * The headers already arrived, so this is the body phase even though the status is 4xx.
298370 */
299- const parseErrorResponse = async ( response : Response ) : Promise < string > => {
300- const text = await response . text ( )
371+ const parseErrorResponse = async ( response : Response , startedAt : number ) : Promise < string > => {
372+ let text : string
373+ try {
374+ text = await response . text ( )
375+ } catch ( error ) {
376+ throw annotateTransportFailure (
377+ error ,
378+ 'reading-response-body' ,
379+ startedAt ,
380+ describeResponse ( response )
381+ )
382+ }
301383 try {
302384 const payload = JSON . parse ( text )
303385 if ( payload ?. error ?. message ) return payload . error . message
@@ -330,6 +412,7 @@ export async function executeResponsesProviderRequest(
330412
331413 const fetchResponsesWithSummaryFallback = async (
332414 requestedBody : Record < string , unknown > ,
415+ startedAt : number ,
333416 abortSignal = request . abortSignal
334417 ) : Promise < Response > => {
335418 const body = reasoningSummariesUnavailable
@@ -343,7 +426,7 @@ export async function executeResponsesProviderRequest(
343426 } )
344427 if ( response . ok ) return response
345428
346- const message = await parseErrorResponse ( response )
429+ const message = await parseErrorResponse ( response , startedAt )
347430 const strippedBody = isReasoningSummaryVerificationError ( response . status , message )
348431 ? stripReasoningSummary ( body )
349432 : null
@@ -363,84 +446,27 @@ export async function executeResponsesProviderRequest(
363446 signal : abortSignal ,
364447 } )
365448 if ( ! retryResponse . ok ) {
366- const retryMessage = await parseErrorResponse ( retryResponse )
449+ const retryMessage = await parseErrorResponse ( retryResponse , startedAt )
367450 throw new Error (
368451 `${ config . providerLabel } API error (${ retryResponse . status } ): ${ retryMessage } `
369452 )
370453 }
371454 return retryResponse
372455 }
373456
374- /**
375- * Names the request phase an opaque transport failure died in.
376- *
377- * Bun raises only `TimeoutError: The operation timed out.`, which cannot distinguish
378- * "never answered" from "answered, but the body never arrived" — opposite owners,
379- * opposite fixes. undici splits these as `UND_ERR_HEADERS_TIMEOUT` vs
380- * `UND_ERR_BODY_TIMEOUT`; this records the equivalent for a runtime that reports
381- * neither.
382- *
383- * The phase rides the error message because that reaches the block's trace span, which
384- * survives when a task has stopped shipping logs; `x-request-id` is the only handle the
385- * provider can trace the call by. Self-describing API errors are left untouched.
386- */
387- const annotateTransportFailure = (
388- error : unknown ,
389- phase : 'awaiting-response-headers' | 'reading-response-body' ,
390- startedAt : number ,
391- detail ?: Record < string , string | number | null >
392- ) : unknown => {
393- if ( ! ( error instanceof Error ) ) return error
394- if ( error . name !== 'TimeoutError' && error . name !== 'AbortError' ) return error
395-
396- const elapsedMs = Date . now ( ) - startedAt
397- const fields = Object . entries ( detail ?? { } )
398- . filter ( ( [ , value ] ) => value !== null && value !== undefined )
399- . map ( ( [ key , value ] ) => `${ key } =${ value } ` )
400- const context = [ `phase=${ phase } ` , `elapsedMs=${ elapsedMs } ` , ...fields ] . join ( ' ' )
401-
402- logger . error ( `${ config . providerLabel } request failed in transport` , {
403- phase,
404- elapsedMs,
405- errorName : error . name ,
406- model : config . modelName ,
407- workflowId : request . workflowId ,
408- blockId : request . blockId ,
409- executionId : request . executionId ,
410- ...detail ,
411- } )
412-
413- /**
414- * A new Error rather than a mutation: the runtime raises these as `DOMException`,
415- * whose `message` is a readonly getter, so assigning to it throws a `TypeError` and
416- * destroys the very failure being reported. `name` is copied and the original hangs
417- * off `cause` so the classification survives the `ProviderError` wrapping below,
418- * which overwrites `name`.
419- */
420- const annotated = new Error ( `${ error . message } [${ context } ]` , { cause : error } )
421- annotated . name = error . name
422- return annotated
423- }
424-
425457 const postResponses = async (
426458 body : Record < string , unknown >
427459 ) : Promise < OpenAI . Responses . Response > => {
428460 const startedAt = Date . now ( )
429461
430462 let response : Response
431463 try {
432- response = await fetchResponsesWithSummaryFallback ( body )
464+ response = await fetchResponsesWithSummaryFallback ( body , startedAt )
433465 } catch ( error ) {
434466 throw annotateTransportFailure ( error , 'awaiting-response-headers' , startedAt )
435467 }
436468
437- const responseMeta = {
438- status : response . status ,
439- ttfbMs : Date . now ( ) - startedAt ,
440- requestId : response . headers . get ( 'x-request-id' ) ,
441- contentLength : response . headers . get ( 'content-length' ) ,
442- contentEncoding : response . headers . get ( 'content-encoding' ) ,
443- }
469+ const responseMeta = { ...describeResponse ( response ) , ttfbMs : Date . now ( ) - startedAt }
444470
445471 let parsed : OpenAI . Responses . Response
446472 try {
@@ -492,7 +518,11 @@ export async function executeResponsesProviderRequest(
492518 initialToolChoice : responsesToolChoice ,
493519 forcedTools : preparedTools ?. forcedTools ,
494520 createStream : ( input , overrides , abortSignal ) =>
495- fetchResponsesWithSummaryFallback ( createRequestBody ( input , overrides ) , abortSignal ) ,
521+ fetchResponsesWithSummaryFallback (
522+ createRequestBody ( input , overrides ) ,
523+ Date . now ( ) ,
524+ abortSignal
525+ ) ,
496526 logger,
497527 timeSegments,
498528 onComplete : ( result ) => {
@@ -516,7 +546,8 @@ export async function executeResponsesProviderRequest(
516546 logger . info ( `Using streaming response for ${ config . providerLabel } request` )
517547
518548 const streamResponse = await fetchResponsesWithSummaryFallback (
519- createRequestBody ( initialInput , { stream : true } )
549+ createRequestBody ( initialInput , { stream : true } ) ,
550+ Date . now ( )
520551 )
521552
522553 const streamingResult = createStreamingExecution ( {
0 commit comments