diff --git a/src/server/__tests__/proxy-network-settings.test.ts b/src/server/__tests__/proxy-network-settings.test.ts index 7845ac50..d4bdb812 100644 --- a/src/server/__tests__/proxy-network-settings.test.ts +++ b/src/server/__tests__/proxy-network-settings.test.ts @@ -180,7 +180,7 @@ describe('proxy network settings', () => { } }) - test('uses configured AI request timeout for streaming upstream requests', async () => { + test('uses configured AI request timeout only while opening streaming upstream requests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), JSON.stringify({ @@ -209,8 +209,12 @@ describe('proxy network settings', () => { const originalFetch = globalThis.fetch const originalTimeout = AbortSignal.timeout + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout const timeoutCalls: number[] = [] - globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { + const timers: Array<{ ms: number | undefined; cleared: boolean }> = [] + globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal) return new Response( new ReadableStream({ start(controller) { @@ -228,6 +232,15 @@ describe('proxy network settings', () => { timeoutCalls.push(ms) return originalTimeout(ms) }) as typeof AbortSignal.timeout + globalThis.setTimeout = ((handler: TimerHandler, ms?: number, ...args: unknown[]) => { + const timer = { ms, cleared: false } + timers.push(timer) + return timer as unknown as ReturnType + }) as typeof setTimeout + globalThis.clearTimeout = ((timer?: ReturnType) => { + const found = timers.find((entry) => entry === timer) + if (found) found.cleared = true + }) as typeof clearTimeout try { const body = { @@ -245,10 +258,14 @@ describe('proxy network settings', () => { }, ) const res = await handleProxyRequest(req, new URL(req.url)) + await res.text() expect(res.status).toBe(200) - expect(timeoutCalls).toEqual([180_000]) + expect(timeoutCalls).toEqual([]) + expect(timers).toEqual([{ ms: 180_000, cleared: true }]) } finally { + globalThis.clearTimeout = originalClearTimeout + globalThis.setTimeout = originalSetTimeout AbortSignal.timeout = originalTimeout globalThis.fetch = originalFetch } diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 1d9dc546..cb0fc7e4 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -24,6 +24,50 @@ import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/netwo const providerService = new ProviderService() +type ProxyFetchOptions = ReturnType +type UpstreamRequestInit = RequestInit & ProxyFetchOptions + +function createTimeoutController(timeoutMs: number): { + signal: AbortSignal + clear: () => void +} { + const controller = new AbortController() + const timer = setTimeout(() => { + controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) + }, timeoutMs) + + return { + signal: controller.signal, + clear: () => clearTimeout(timer), + } +} + +async function fetchUpstreamWithTimeout( + url: string, + init: Omit, + timeoutMs: number, + isStream: boolean, +): Promise { + if (!isStream) { + return fetch(url, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }) + } + + // For streaming requests, this timeout should only cover the connection and + // response headers. Keeping the signal alive aborts long generations mid-body. + const timeout = createTimeoutController(timeoutMs) + try { + return await fetch(url, { + ...init, + signal: timeout.signal, + }) + } finally { + timeout.clear() + } +} + export async function handleProxyRequest(req: Request, url: URL): Promise { const providerMatch = url.pathname.match(/^\/proxy\/providers\/([^/]+)\/v1\/messages$/) const providerId = providerMatch ? decodeURIComponent(providerMatch[1]!) : undefined @@ -127,16 +171,15 @@ async function handleOpenaiChat( const url = `${baseUrl}/v1/chat/completions` const proxyOptions = getProxyFetchOptions({ proxyUrl }) - const upstream = await fetch(url, { + const upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - signal: AbortSignal.timeout(aiRequestTimeoutMs), ...proxyOptions, - }) + }, aiRequestTimeoutMs, isStream) if (!upstream.ok) { const errText = await upstream.text().catch(() => '') @@ -195,16 +238,15 @@ async function handleOpenaiResponses( const url = `${baseUrl}/v1/responses` const proxyOptions = getProxyFetchOptions({ proxyUrl }) - const upstream = await fetch(url, { + const upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - signal: AbortSignal.timeout(aiRequestTimeoutMs), ...proxyOptions, - }) + }, aiRequestTimeoutMs, isStream) if (!upstream.ok) { const errText = await upstream.text().catch(() => '')