Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/sim/providers/openai/core.deadline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @vitest-environment node
*
* A non-streaming generation is silent on the wire until it finishes, so nothing but an
* explicit deadline can bound it. A streaming one emits continuously and must NOT carry
* a total deadline, or a long answer still arriving normally gets cut off.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { executeResponsesProviderRequest } from '@/providers/openai/core'
import { PROVIDER_REQUEST_TIMEOUT_MS } from '@/providers/timeouts'
import type { ProviderRequest } from '@/providers/types'

vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))

vi.mock('@/providers/utils', () => ({
isFunctionToolCall: () => false,
calculateCost: () => ({ input: 0, output: 0, total: 0 }),
sumToolCosts: () => 0,
enforceStrictSchema: (schema: unknown) => schema,
prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }),
prepareToolsWithUsageControl: (tools: unknown[]) => ({
tools,
toolChoice: undefined,
forcedTools: [],
hasFilteredTools: false,
}),
trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }),
supportsReasoningEffort: () => false,
}))

const COMPLETED = {
id: 'resp_1',
status: 'completed',
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}

describe('provider request deadline', () => {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never

beforeEach(() => vi.clearAllMocks())

function run(fetchMock: unknown, request: Partial<ProviderRequest> = {}) {
return executeResponsesProviderRequest(
{ apiKey: 'k', model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }], ...request },
{
providerId: 'openai',
providerLabel: 'OpenAI',
modelName: 'gpt-5.5',
endpoint: 'https://api.openai.com/v1/responses',
headers: { Authorization: 'Bearer k' },
logger,
fetch: fetchMock as typeof fetch,
}
)
}

function okResponse() {
return { ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve(COMPLETED) }
}

/** Ten minutes, matching the OpenAI client's own documented default. */
it('matches the vendor default rather than inventing a number', () => {
expect(PROVIDER_REQUEST_TIMEOUT_MS).toBe(600_000)
})

it('arms a deadline on a non-streaming request', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse())
await run(fetchMock)

const signal = fetchMock.mock.calls[0][1].signal as AbortSignal
expect(signal).toBeInstanceOf(AbortSignal)
expect(signal.aborted).toBe(false)
})

/**
* The runtime's idle timer already bounds a stalled stream correctly. A total deadline
* here would kill a long answer that is still arriving.
*/
it('does not arm a deadline on a streaming request', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse())
await run(fetchMock, { stream: true }).catch(() => {})

const streamCall = fetchMock.mock.calls.find(
(c) => JSON.parse(c[1].body as string).stream === true
)
expect(streamCall).toBeDefined()
expect(streamCall?.[1].signal).toBeUndefined()
})

/** A user pressing Stop must still win over the deadline. */
it('preserves the caller signal alongside the deadline', async () => {
const controller = new AbortController()
const fetchMock = vi.fn().mockResolvedValue(okResponse())
await run(fetchMock, { abortSignal: controller.signal })

const signal = fetchMock.mock.calls[0][1].signal as AbortSignal
expect(signal.aborted).toBe(false)
controller.abort()
expect(signal.aborted).toBe(true)
})
})
23 changes: 22 additions & 1 deletion apps/sim/providers/openai/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { executeProviderTool } from '@/providers/runtime-context'
import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { PROVIDER_REQUEST_TIMEOUT_MS } from '@/providers/timeouts'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
import { ProviderError } from '@/providers/types'
Expand Down Expand Up @@ -410,6 +411,26 @@ export async function executeResponsesProviderRequest(

let reasoningSummariesUnavailable = false

/**
* Bounds a non-streaming request, and deliberately leaves a streaming one alone.
*
* A non-streaming generation is silent on the wire until it completes, so there is no
* liveness signal an idle timer could act on — the deadline has to be explicit. A
* streaming response emits continuously, which is precisely what the runtime's idle
* timer is built for, and a total deadline there would cut off a long answer that is
* still arriving normally.
*
* The caller's own signal is preserved: a user pressing Stop must still win.
*/
const withRequestDeadline = (
abortSignal: AbortSignal | undefined,
streaming: boolean
): AbortSignal | undefined => {
if (streaming) return abortSignal
const deadline = AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)
return abortSignal ? AbortSignal.any([abortSignal, deadline]) : deadline
}

/**
* The single point every Responses request leaves through, so a stall waiting for
* headers is named on the streaming paths too — they call
Expand All @@ -426,7 +447,7 @@ export async function executeResponsesProviderRequest(
method: 'POST',
headers: config.headers,
body: JSON.stringify(payload),
signal: abortSignal,
signal: withRequestDeadline(abortSignal, payload.stream === true),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deadline ignored by Bun idle timer

High Severity

On Bun 1.3.14, AbortSignal.timeout does not outrank the runtime's ~300s idle fetch limit, so silent non-streaming generations can still die around five minutes—the failure mode this change aims to fix. The new 600s deadline never becomes the effective bound unless that idle timer is disarmed on the request.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit db40573. Configure here.

})
} catch (error) {
throw annotateTransportFailure(error, 'awaiting-response-headers', startedAt)
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/providers/timeouts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Deadline for a single non-streaming provider request, matching the OpenAI client's own
* documented default (`node_modules/openai/client.d.ts`: `[opts.timeout=10 minutes]`).
*
* Without an explicit value the request inherits whatever the runtime imposes — under Bun
* that is an undocumented ~300s idle timer, half the vendor's default and chosen by nobody.
* Measured production failures at 295.8s and 278.9s were generations still in progress, not
* stalled connections.
*
* Deliberately its own module rather than the `@/providers` barrel: that barrel is replaced
* wholesale by `vi.mock` in 21 test files, so an export added there resolves to `undefined`
* in all of them.
*/
export const PROVIDER_REQUEST_TIMEOUT_MS = 600_000
Loading