11import { createHash } from 'node:crypto'
22import type { Logger } from '@sim/logger'
33import { getErrorMessage , toError } from '@sim/utils/errors'
4- import { sleep } from '@sim/utils/helpers'
54import { isRecordLike } from '@sim/utils/object'
65import { backoffWithJitter , parseRetryAfter } from '@sim/utils/retry'
76import { truncate } from '@sim/utils/string'
@@ -112,6 +111,32 @@ function readRetryAfterMs(headers: Headers): number | null {
112111 return parseRetryAfter ( headers . get ( 'retry-after' ) )
113112}
114113
114+ /**
115+ * Waits out a retry backoff, resolving early — and rejecting with the caller's own
116+ * abort reason — the moment the run is cancelled.
117+ *
118+ * A plain `sleep` would hold the provider slot for the full delay after a workflow was
119+ * already cancelled, and the loop would then surface the stale HTTP error rather than
120+ * the cancellation, reporting a cancelled run as a rate limit or a 5xx.
121+ */
122+ function backoffDelay ( ms : number , signal : AbortSignal | undefined ) : Promise < void > {
123+ return new Promise ( ( resolve , reject ) => {
124+ if ( signal ?. aborted ) {
125+ reject ( signal . reason )
126+ return
127+ }
128+ const onAbort = ( ) => {
129+ clearTimeout ( timer )
130+ reject ( signal ?. reason )
131+ }
132+ const timer = setTimeout ( ( ) => {
133+ signal ?. removeEventListener ( 'abort' , onAbort )
134+ resolve ( )
135+ } , ms )
136+ signal ?. addEventListener ( 'abort' , onAbort , { once : true } )
137+ } )
138+ }
139+
115140type PreparedTools = ReturnType < typeof prepareToolsWithUsageControl >
116141type ToolChoice = PreparedTools [ 'toolChoice' ]
117142
@@ -406,22 +431,57 @@ export async function executeResponsesProviderRequest(
406431
407432 let reasoningSummariesUnavailable = false
408433
434+ /**
435+ * One POST, paired with a deadline that can bound any body read on the response.
436+ *
437+ * The deadline is created here rather than by the caller because a non-2xx body is
438+ * read inside this function, before the caller ever sees the response — an error body
439+ * that stalls would otherwise hang unbounded until the runtime's socket wall, which is
440+ * exactly the failure this change exists to remove.
441+ */
442+ const postOnce = async (
443+ bodyToSend : Record < string , unknown > ,
444+ abortSignal : AbortSignal | undefined
445+ ) : Promise < { response : Response ; bodyDeadline : AbortController } > => {
446+ const bodyDeadline = new AbortController ( )
447+ const signal = abortSignal
448+ ? AbortSignal . any ( [ abortSignal , bodyDeadline . signal ] )
449+ : bodyDeadline . signal
450+ const response = await fetchImpl ( config . endpoint , {
451+ method : 'POST' ,
452+ headers : config . headers ,
453+ body : JSON . stringify ( bodyToSend ) ,
454+ signal,
455+ } )
456+ return { response, bodyDeadline }
457+ }
458+
459+ /** Reads a non-2xx body under the same deadline that bounds a successful one. */
460+ const readErrorBody = async (
461+ response : Response ,
462+ bodyDeadline : AbortController
463+ ) : Promise < string > => {
464+ const timer = setTimeout ( ( ) => {
465+ bodyDeadline . abort ( new DOMException ( 'response body stalled' , 'TimeoutError' ) )
466+ } , RESPONSE_BODY_BUDGET_MS )
467+ try {
468+ return await parseErrorResponse ( response )
469+ } finally {
470+ clearTimeout ( timer )
471+ }
472+ }
473+
409474 const fetchResponsesAttempt = async (
410475 requestedBody : Record < string , unknown > ,
411476 abortSignal : AbortSignal | undefined
412477 ) : Promise < Response > => {
413478 const body = reasoningSummariesUnavailable
414479 ? ( stripReasoningSummary ( requestedBody ) ?? requestedBody )
415480 : requestedBody
416- const response = await fetchImpl ( config . endpoint , {
417- method : 'POST' ,
418- headers : config . headers ,
419- body : JSON . stringify ( body ) ,
420- signal : abortSignal ,
421- } )
481+ const { response, bodyDeadline } = await postOnce ( body , abortSignal )
422482 if ( response . ok ) return response
423483
424- const message = await parseErrorResponse ( response )
484+ const message = await readErrorBody ( response , bodyDeadline )
425485 const strippedBody = isReasoningSummaryVerificationError ( response . status , message )
426486 ? stripReasoningSummary ( body )
427487 : null
@@ -438,14 +498,12 @@ export async function executeResponsesProviderRequest(
438498 `${ config . providerLabel } rejected reasoning summaries (organization not verified); retrying without summary` ,
439499 { model : config . modelName }
440500 )
441- const retryResponse = await fetchImpl ( config . endpoint , {
442- method : 'POST' ,
443- headers : config . headers ,
444- body : JSON . stringify ( strippedBody ) ,
445- signal : abortSignal ,
446- } )
501+ const { response : retryResponse , bodyDeadline : retryDeadline } = await postOnce (
502+ strippedBody ,
503+ abortSignal
504+ )
447505 if ( ! retryResponse . ok ) {
448- const retryMessage = await parseErrorResponse ( retryResponse )
506+ const retryMessage = await readErrorBody ( retryResponse , retryDeadline )
449507 throw new ResponsesHttpError (
450508 `${ config . providerLabel } API error (${ retryResponse . status } ): ${ retryMessage } ` ,
451509 retryResponse . status ,
@@ -475,10 +533,18 @@ export async function executeResponsesProviderRequest(
475533 try {
476534 return await fetchResponsesAttempt ( requestedBody , abortSignal )
477535 } catch ( error ) {
536+ /**
537+ * A cancelled run reports the cancellation, never the status that happened to be
538+ * in flight when it was cancelled — surfacing the stale error would report a
539+ * cancelled run as a rate limit or a 5xx.
540+ */
541+ if ( abortSignal ?. aborted ) {
542+ throw abortSignal . reason ?? error
543+ }
544+
478545 const exhausted = attempt > MAX_RESPONSES_RETRIES
479546 if (
480547 exhausted ||
481- abortSignal ?. aborted ||
482548 ! ( error instanceof ResponsesHttpError ) ||
483549 ! isRetryableResponseStatus ( error . status )
484550 ) {
@@ -495,8 +561,7 @@ export async function executeResponsesProviderRequest(
495561 blockId : request . blockId ,
496562 executionId : request . executionId ,
497563 } )
498- await sleep ( delayMs )
499- if ( abortSignal ?. aborted ) throw error
564+ await backoffDelay ( delayMs , abortSignal )
500565 }
501566 }
502567 }
0 commit comments