Skip to content

Commit f403ceb

Browse files
committed
fix(providers): bound stalled error bodies and make retry backoff cancellable
1 parent 40ec8ee commit f403ceb

2 files changed

Lines changed: 123 additions & 18 deletions

File tree

apps/sim/providers/openai/core.retry.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,46 @@ describe('OpenAI Responses status retries', () => {
244244
}
245245
})
246246

247+
it('bounds a stalled error body instead of hanging on it', async () => {
248+
// Non-2xx headers, then an error body that never settles.
249+
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => ({
250+
ok: false,
251+
status: 400,
252+
headers: new Headers(),
253+
text: () =>
254+
new Promise<string>((_resolve, reject) => {
255+
if (init.signal?.aborted) {
256+
reject(new DOMException('The operation timed out.', 'TimeoutError'))
257+
return
258+
}
259+
init.signal?.addEventListener(
260+
'abort',
261+
() => reject(new DOMException('The operation timed out.', 'TimeoutError')),
262+
{ once: true }
263+
)
264+
}),
265+
}))
266+
267+
const settled = await runWithTimers(fetchMock)
268+
269+
expect(settled).toBeInstanceOf(Error)
270+
expect(fetchMock).toHaveBeenCalledTimes(1)
271+
})
272+
273+
it('abandons backoff immediately when the caller aborts, and reports the abort', async () => {
274+
const caller = new AbortController()
275+
const fetchMock = vi.fn().mockImplementation(() => {
276+
queueMicrotask(() => caller.abort(new DOMException('timeout', 'AbortError')))
277+
return errorResponse(429)
278+
})
279+
280+
const settled = (await runWithTimers(fetchMock, { abortSignal: caller.signal })) as Error
281+
282+
// The cancellation must surface as the abort, not as the stale 429.
283+
expect(settled.message).not.toContain('429')
284+
expect(fetchMock).toHaveBeenCalledTimes(1)
285+
})
286+
247287
it('does not retry when the caller aborts', async () => {
248288
const caller = new AbortController()
249289
const fetchMock = vi.fn().mockImplementation(() => {

apps/sim/providers/openai/core.ts

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createHash } from 'node:crypto'
22
import type { Logger } from '@sim/logger'
33
import { getErrorMessage, toError } from '@sim/utils/errors'
4-
import { sleep } from '@sim/utils/helpers'
54
import { isRecordLike } from '@sim/utils/object'
65
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
76
import { 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+
115140
type PreparedTools = ReturnType<typeof prepareToolsWithUsageControl>
116141
type 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

Comments
 (0)