Skip to content

Commit 678384b

Browse files
committed
fix(providers): name the body phase when an error body read fails
1 parent 0655825 commit 678384b

2 files changed

Lines changed: 104 additions & 68 deletions

File tree

apps/sim/providers/openai/core.transport-phase.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,12 @@ describe('OpenAI transport phase annotation', () => {
190190
const error = await run(vi.fn().mockResolvedValue(unreadable)).catch((e) => e)
191191

192192
expect(error.message).toContain('The operation timed out.')
193-
expect(error.message).not.toContain('502')
193+
expect(error.message).not.toContain('API error')
194+
// The headers already arrived, so this is the body phase despite the 4xx/5xx status.
195+
expect(error.message).toContain('phase=reading-response-body')
196+
expect(error.message).toContain('status=502')
197+
// Annotated exactly once: the outer catch must not append a second, wrong phase.
198+
expect(error.message.match(/phase=/g)).toHaveLength(1)
194199
})
195200

196201
it('leaves a healthy response entirely unaffected', async () => {

apps/sim/providers/openai/core.ts

Lines changed: 98 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
8996
type PreparedTools = ReturnType<typeof prepareToolsWithUsageControl>
9097
type 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

Comments
 (0)