Skip to content

Commit db40573

Browse files
committed
fix(providers): bound a non-streaming request with an explicit 10-minute deadline
1 parent 5baa7a4 commit db40573

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* A non-streaming generation is silent on the wire until it finishes, so nothing but an
5+
* explicit deadline can bound it. A streaming one emits continuously and must NOT carry
6+
* a total deadline, or a long answer still arriving normally gets cut off.
7+
*/
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { executeResponsesProviderRequest } from '@/providers/openai/core'
10+
import { PROVIDER_REQUEST_TIMEOUT_MS } from '@/providers/timeouts'
11+
import type { ProviderRequest } from '@/providers/types'
12+
13+
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))
14+
15+
vi.mock('@/providers/utils', () => ({
16+
isFunctionToolCall: () => false,
17+
calculateCost: () => ({ input: 0, output: 0, total: 0 }),
18+
sumToolCosts: () => 0,
19+
enforceStrictSchema: (schema: unknown) => schema,
20+
prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }),
21+
prepareToolsWithUsageControl: (tools: unknown[]) => ({
22+
tools,
23+
toolChoice: undefined,
24+
forcedTools: [],
25+
hasFilteredTools: false,
26+
}),
27+
trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }),
28+
supportsReasoningEffort: () => false,
29+
}))
30+
31+
const COMPLETED = {
32+
id: 'resp_1',
33+
status: 'completed',
34+
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }],
35+
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
36+
}
37+
38+
describe('provider request deadline', () => {
39+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never
40+
41+
beforeEach(() => vi.clearAllMocks())
42+
43+
function run(fetchMock: unknown, request: Partial<ProviderRequest> = {}) {
44+
return executeResponsesProviderRequest(
45+
{ apiKey: 'k', model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }], ...request },
46+
{
47+
providerId: 'openai',
48+
providerLabel: 'OpenAI',
49+
modelName: 'gpt-5.5',
50+
endpoint: 'https://api.openai.com/v1/responses',
51+
headers: { Authorization: 'Bearer k' },
52+
logger,
53+
fetch: fetchMock as typeof fetch,
54+
}
55+
)
56+
}
57+
58+
function okResponse() {
59+
return { ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve(COMPLETED) }
60+
}
61+
62+
/** Ten minutes, matching the OpenAI client's own documented default. */
63+
it('matches the vendor default rather than inventing a number', () => {
64+
expect(PROVIDER_REQUEST_TIMEOUT_MS).toBe(600_000)
65+
})
66+
67+
it('arms a deadline on a non-streaming request', async () => {
68+
const fetchMock = vi.fn().mockResolvedValue(okResponse())
69+
await run(fetchMock)
70+
71+
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal
72+
expect(signal).toBeInstanceOf(AbortSignal)
73+
expect(signal.aborted).toBe(false)
74+
})
75+
76+
/**
77+
* The runtime's idle timer already bounds a stalled stream correctly. A total deadline
78+
* here would kill a long answer that is still arriving.
79+
*/
80+
it('does not arm a deadline on a streaming request', async () => {
81+
const fetchMock = vi.fn().mockResolvedValue(okResponse())
82+
await run(fetchMock, { stream: true }).catch(() => {})
83+
84+
const streamCall = fetchMock.mock.calls.find(
85+
(c) => JSON.parse(c[1].body as string).stream === true
86+
)
87+
expect(streamCall).toBeDefined()
88+
expect(streamCall?.[1].signal).toBeUndefined()
89+
})
90+
91+
/** A user pressing Stop must still win over the deadline. */
92+
it('preserves the caller signal alongside the deadline', async () => {
93+
const controller = new AbortController()
94+
const fetchMock = vi.fn().mockResolvedValue(okResponse())
95+
await run(fetchMock, { abortSignal: controller.signal })
96+
97+
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal
98+
expect(signal.aborted).toBe(false)
99+
controller.abort()
100+
expect(signal.aborted).toBe(true)
101+
})
102+
})

apps/sim/providers/openai/core.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { executeProviderTool } from '@/providers/runtime-context'
1818
import { createStreamingExecution } from '@/providers/streaming-execution'
1919
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
20+
import { PROVIDER_REQUEST_TIMEOUT_MS } from '@/providers/timeouts'
2021
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
2122
import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
2223
import { ProviderError } from '@/providers/types'
@@ -410,6 +411,26 @@ export async function executeResponsesProviderRequest(
410411

411412
let reasoningSummariesUnavailable = false
412413

414+
/**
415+
* Bounds a non-streaming request, and deliberately leaves a streaming one alone.
416+
*
417+
* A non-streaming generation is silent on the wire until it completes, so there is no
418+
* liveness signal an idle timer could act on — the deadline has to be explicit. A
419+
* streaming response emits continuously, which is precisely what the runtime's idle
420+
* timer is built for, and a total deadline there would cut off a long answer that is
421+
* still arriving normally.
422+
*
423+
* The caller's own signal is preserved: a user pressing Stop must still win.
424+
*/
425+
const withRequestDeadline = (
426+
abortSignal: AbortSignal | undefined,
427+
streaming: boolean
428+
): AbortSignal | undefined => {
429+
if (streaming) return abortSignal
430+
const deadline = AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)
431+
return abortSignal ? AbortSignal.any([abortSignal, deadline]) : deadline
432+
}
433+
413434
/**
414435
* The single point every Responses request leaves through, so a stall waiting for
415436
* headers is named on the streaming paths too — they call
@@ -426,7 +447,7 @@ export async function executeResponsesProviderRequest(
426447
method: 'POST',
427448
headers: config.headers,
428449
body: JSON.stringify(payload),
429-
signal: abortSignal,
450+
signal: withRequestDeadline(abortSignal, payload.stream === true),
430451
})
431452
} catch (error) {
432453
throw annotateTransportFailure(error, 'awaiting-response-headers', startedAt)

apps/sim/providers/timeouts.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Deadline for a single non-streaming provider request, matching the OpenAI client's own
3+
* documented default (`node_modules/openai/client.d.ts`: `[opts.timeout=10 minutes]`).
4+
*
5+
* Without an explicit value the request inherits whatever the runtime imposes — under Bun
6+
* that is an undocumented ~300s idle timer, half the vendor's default and chosen by nobody.
7+
* Measured production failures at 295.8s and 278.9s were generations still in progress, not
8+
* stalled connections.
9+
*
10+
* Deliberately its own module rather than the `@/providers` barrel: that barrel is replaced
11+
* wholesale by `vi.mock` in 21 test files, so an export added there resolves to `undefined`
12+
* in all of them.
13+
*/
14+
export const PROVIDER_REQUEST_TIMEOUT_MS = 600_000

0 commit comments

Comments
 (0)