From 6f99ccb478cf082bfaea7d462b432602ceb4a151 Mon Sep 17 00:00:00 2001 From: chioarub Date: Sat, 11 Jul 2026 09:19:01 +0300 Subject: [PATCH 01/17] fix(api): enforce API_TIMEOUT_MS for OpenAI-compatible headers --- src/services/api/openaiShim.test.ts | 297 ++++++++++++++++++++++++++++ src/services/api/openaiShim.ts | 171 +++++++++++++++- 2 files changed, 459 insertions(+), 9 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 067cf44177..5634ab2826 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -55,6 +55,7 @@ const originalEnv = { CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, CLAUDE_STREAM_IDLE_TIMEOUT_MS: process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS, + API_TIMEOUT_MS: process.env.API_TIMEOUT_MS, } const originalFetch = globalThis.fetch @@ -363,6 +364,7 @@ function importFreshOpenAIShim( type StreamIdleTestApi = { StreamIdleTimeoutError: new (timeoutMs: number) => Error + getApiTimeoutMs: () => number getStreamIdleTimeoutMs: () => number readWithIdleTimeout: ( reader: ReadableStreamDefaultReader, @@ -375,6 +377,7 @@ async function getStreamIdleTestApi(cacheKey: string): Promise expect(typeof testApi.StreamIdleTimeoutError).toBe('function') + expect(typeof testApi.getApiTimeoutMs).toBe('function') expect(typeof testApi.getStreamIdleTimeoutMs).toBe('function') expect(typeof testApi.readWithIdleTimeout).toBe('function') return testApi as StreamIdleTestApi @@ -403,6 +406,21 @@ function makeChatCompletionResponse(model: string): Response { ) } +function pendingFetchUntilAbort( + init: RequestInit | undefined, +): Promise { + return new Promise((_resolve, reject) => { + const signal = init?.signal + if (!signal) return + + const rejectFromAbort = () => { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) + } + signal.addEventListener('abort', rejectFromAbort, { once: true }) + if (signal.aborted) rejectFromAbort() + }) +} + async function captureChatCompletionRequest( model = 'mimo-v2.5-pro', ): Promise<{ authorization: string | null; url: string | null }> { @@ -470,6 +488,7 @@ beforeEach(async () => { delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID delete process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS + delete process.env.API_TIMEOUT_MS }) afterEach(() => { @@ -513,6 +532,7 @@ afterEach(() => { restoreEnv('CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED) restoreEnv('CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID) restoreEnv('CLAUDE_STREAM_IDLE_TIMEOUT_MS', originalEnv.CLAUDE_STREAM_IDLE_TIMEOUT_MS) + restoreEnv('API_TIMEOUT_MS', originalEnv.API_TIMEOUT_MS) globalThis.fetch = originalFetch _clearRegistryForTesting() ensureIntegrationsLoaded() @@ -1582,6 +1602,27 @@ test('stream idle timeout env parser parses and bounds overrides', async () => { expect(testApi.getStreamIdleTimeoutMs()).toBe(90_000) }) +test('API timeout env parser accepts safe positive integers and falls back otherwise', async () => { + const testApi = await getStreamIdleTestApi('api-timeout-env-parser') + + delete process.env.API_TIMEOUT_MS + expect(testApi.getApiTimeoutMs()).toBe(600_000) + + process.env.API_TIMEOUT_MS = '50' + expect(testApi.getApiTimeoutMs()).toBe(50) + + process.env.API_TIMEOUT_MS = ' 50 ' + expect(testApi.getApiTimeoutMs()).toBe(50) + + process.env.API_TIMEOUT_MS = '3000000000' + expect(testApi.getApiTimeoutMs()).toBe(2_147_483_647) + + for (const invalid of ['abc', '-5', '', '0', '1.5', '9007199254740993']) { + process.env.API_TIMEOUT_MS = invalid + expect(testApi.getApiTimeoutMs()).toBe(600_000) + } +}) + test('Anthropic-compatible passthrough stream rejects with idle timeout when it stalls', async () => { process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS = '25' const stalled = makeStallingResponse( @@ -6829,6 +6870,262 @@ test('propagates AbortError without wrapping it as transport failure', async () ).rejects.toBe(abortError) }) +test('classifies a pre-header API timeout as a retryable transport failure', async () => { + process.env.API_TIMEOUT_MS = '20' + process.env.OPENAI_BASE_URL = + 'https://user:password@slow.example.test/v1?token=secret' + let fetchCalls = 0 + globalThis.fetch = (async (_input, init) => { + fetchCalls++ + return pendingFetchUntilAbort(init) + }) as unknown as FetchType + + const safety = new AbortController() + const safetyTimer = setTimeout(() => safety.abort(), 500) + const client = createOpenAIShimClient({}) as OpenAIShimClient + + let caught: unknown + try { + await waitForPromise( + client.beta.messages.create( + { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }, + { signal: safety.signal }, + ), + 750, + 'pre-header timeout did not settle', + ) + } catch (error) { + caught = error + } finally { + clearTimeout(safetyTimer) + } + + expect(caught).toBeDefined() + const error = caught as Error & { constructor: { name: string } } + expect(error.constructor.name).toBe('APIConnectionError') + expect(error.message).toContain('no response headers within 20ms (API_TIMEOUT_MS)') + expect(error.message).toContain('slow.example.test') + expect(error.message).toContain('openai_category=request_timeout') + expect(error.message).not.toContain('password') + expect(error.message).not.toContain('token=secret') + expect(fetchCalls).toBe(2) +}) + +test('preserves caller cancellation while waiting for response headers without retrying', async () => { + process.env.API_TIMEOUT_MS = '200' + let fetchCalls = 0 + globalThis.fetch = (async (_input, init) => { + fetchCalls++ + return pendingFetchUntilAbort(init) + }) as unknown as FetchType + + const caller = new AbortController() + const callerReason = new DOMException('Cancelled by user', 'AbortError') + const client = createOpenAIShimClient({}) as OpenAIShimClient + const originalAbortSignalAny = Object.getOwnPropertyDescriptor( + AbortSignal, + 'any', + ) + Object.defineProperty(AbortSignal, 'any', { + value: undefined, + configurable: true, + }) + try { + const request = client.beta.messages.create( + { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }, + { signal: caller.signal }, + ) + + setTimeout(() => caller.abort(callerReason), 10) + + await expect( + waitForPromise(request, 500, 'caller abort did not settle'), + ).rejects.toBe(callerReason) + expect(fetchCalls).toBe(1) + } finally { + if (originalAbortSignalAny) { + Object.defineProperty(AbortSignal, 'any', originalAbortSignalAny) + } + } +}) + +test('caller abort winning the timeout catch race prevents a retry', async () => { + process.env.API_TIMEOUT_MS = '20' + let fetchCalls = 0 + const caller = new AbortController() + const callerReason = new DOMException('Cancelled by user', 'AbortError') + globalThis.fetch = (async (_input, init) => { + fetchCalls++ + return new Promise((_resolve, reject) => { + const signal = init?.signal + if (!signal) return + const rejectFromAbort = () => { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) + if (fetchCalls === 1) { + queueMicrotask(() => caller.abort(callerReason)) + } + } + signal.addEventListener('abort', rejectFromAbort, { once: true }) + if (signal.aborted) rejectFromAbort() + }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + waitForPromise( + client.beta.messages.create( + { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }, + { signal: caller.signal }, + ), + 500, + 'caller abort race did not settle', + ), + ).rejects.toBe(callerReason) + expect(fetchCalls).toBe(1) +}) + +test('manual signal fallback preserves caller cancellation after headers arrive', async () => { + process.env.API_TIMEOUT_MS = '200' + const fetchSignals: AbortSignal[] = [] + const stalled = makeStallingResponse( + makeOpenAIStreamFrame({ role: 'assistant', content: 'started' }), + ) + globalThis.fetch = (async (_input, init) => { + if (init?.signal) fetchSignals.push(init.signal) + return stalled.response + }) as unknown as FetchType + + const caller = new AbortController() + const client = createOpenAIShimClient({}) as OpenAIShimClient + const originalAbortSignalAny = Object.getOwnPropertyDescriptor( + AbortSignal, + 'any', + ) + Object.defineProperty(AbortSignal, 'any', { + value: undefined, + configurable: true, + }) + try { + const result = await client.beta.messages + .create( + { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: true, + }, + { signal: caller.signal }, + ) + .withResponse() + + await expectAbortStopsStream({ + abort: () => caller.abort(), + cancelReasons: stalled.cancelReasons, + expectedEventsBeforeAbort: 1, + label: 'manual combined signal after headers', + stream: result.data as ShimStream, + }) + + expect(fetchSignals).toHaveLength(1) + expect(fetchSignals[0].aborted).toBe(true) + } finally { + stalled.close() + if (originalAbortSignalAny) { + Object.defineProperty(AbortSignal, 'any', originalAbortSignalAny) + } + } +}) + +test('disarms the API timeout after headers arrive while the body keeps streaming', async () => { + process.env.API_TIMEOUT_MS = '20' + const fetchSignals: AbortSignal[] = [] + const encoder = new TextEncoder() + globalThis.fetch = (async (_input, init) => { + if (init?.signal) fetchSignals.push(init.signal) + return new Response( + new ReadableStream({ + start(controller) { + setTimeout(() => { + controller.enqueue(encoder.encode(makeOpenAIStreamFrame( + { role: 'assistant', content: 'late body' }, + 'stop', + ))) + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, 50) + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) events.push(event) + + expect(events.length).toBeGreaterThan(0) + expect(fetchSignals).toHaveLength(1) + expect(fetchSignals[0].aborted).toBe(false) +}) + +test('retries after a pre-header timeout and succeeds on the next attempt', async () => { + process.env.API_TIMEOUT_MS = '20' + let fetchCalls = 0 + globalThis.fetch = (async (_input, init) => { + fetchCalls++ + if (fetchCalls === 1) return pendingFetchUntilAbort(init) + return makeChatCompletionResponse('gpt-4o-mini') + }) as unknown as FetchType + + const safety = new AbortController() + const safetyTimer = setTimeout(() => safety.abort(), 500) + const client = createOpenAIShimClient({}) as OpenAIShimClient + try { + const response = await waitForPromise( + client.beta.messages.create( + { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }, + { signal: safety.signal }, + ), + 750, + 'timeout retry did not settle', + ) + + expect(response).toBeDefined() + expect(fetchCalls).toBe(2) + } finally { + clearTimeout(safetyTimer) + } +}) + test('classifies chat-completions endpoint 404 failures with endpoint_not_found marker', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434' diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 40b2f15710..81f548cf3c 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -125,6 +125,7 @@ const GITHUB_429_MAX_RETRIES = 3 const GITHUB_429_BASE_DELAY_SEC = 1 const GITHUB_429_MAX_DELAY_SEC = 32 const CREDENTIAL_POOL_COOLDOWN_MS = 30_000 +const DEFAULT_API_TIMEOUT_MS = 600_000 const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000 const MAX_STREAM_IDLE_TIMEOUT_MS = 2_147_483_647 const GEMINI_API_HOST = 'generativelanguage.googleapis.com' @@ -147,6 +148,24 @@ class StreamIdleTimeoutError extends Error { } } +class ResponseHeadersTimeoutError extends Error { + constructor(timeoutMs: number, url: string) { + super( + `OpenAI-compatible request received no response headers within ${timeoutMs}ms (API_TIMEOUT_MS) from ${url}`, + ) + this.name = 'ResponseHeadersTimeoutError' + } +} + +function preserveCallerAbortError( + error: unknown, + callerSignal: AbortSignal, +): unknown { + return error instanceof ResponseHeadersTimeoutError + ? callerSignal.reason ?? error + : error +} + function createStreamAbortError(): DOMException { return new DOMException('Aborted', 'AbortError') } @@ -196,6 +215,67 @@ export function getStreamIdleTimeoutMs(): number { : DEFAULT_STREAM_IDLE_TIMEOUT_MS } +export function getApiTimeoutMs(): number { + const raw = process.env.API_TIMEOUT_MS?.trim() + if (!raw || !/^\d+$/.test(raw)) return DEFAULT_API_TIMEOUT_MS + const parsed = Number(raw) + return Number.isSafeInteger(parsed) && parsed > 0 + ? Math.min(parsed, MAX_STREAM_IDLE_TIMEOUT_MS) + : DEFAULT_API_TIMEOUT_MS +} + +function combineRequestSignals( + callerSignal: AbortSignal | undefined, + deadlineSignal: AbortSignal, +): { + signal: AbortSignal + cleanupAfterHeaders: () => void + cleanup: () => void +} { + if (!callerSignal) { + return { + signal: deadlineSignal, + cleanupAfterHeaders: () => {}, + cleanup: () => {}, + } + } + + if (typeof AbortSignal.any === 'function') { + return { + signal: AbortSignal.any([callerSignal, deadlineSignal]), + cleanupAfterHeaders: () => {}, + cleanup: () => {}, + } + } + + const combined = new AbortController() + const abortFromCaller = () => { + deadlineSignal.removeEventListener('abort', abortFromDeadline) + combined.abort(callerSignal.reason) + } + const abortFromDeadline = () => { + callerSignal.removeEventListener('abort', abortFromCaller) + combined.abort(deadlineSignal.reason) + } + const cleanupAfterHeaders = () => { + deadlineSignal.removeEventListener('abort', abortFromDeadline) + } + const cleanup = () => { + callerSignal.removeEventListener('abort', abortFromCaller) + cleanupAfterHeaders() + } + + callerSignal.addEventListener('abort', abortFromCaller, { once: true }) + deadlineSignal.addEventListener('abort', abortFromDeadline, { once: true }) + if (callerSignal.aborted) { + abortFromCaller() + } else if (deadlineSignal.aborted) { + abortFromDeadline() + } + + return { signal: combined.signal, cleanupAfterHeaders, cleanup } +} + async function readWithIdleTimeout( reader: ReadableStreamDefaultReader, timeoutMs: number, @@ -3784,6 +3864,7 @@ class OpenAIShimMessages { params: ShimCreateParams, options?: { signal?: AbortSignal; headers?: Record }, ): Promise { + const apiTimeoutMs = getApiTimeoutMs() // Local backends (llama.cpp, vLLM, Ollama, LM Studio, …) do not implement // the cloud-side caching/strict-validation behaviours that several of our // pre-send transforms target. Computing the fast-path config once here @@ -4585,16 +4666,62 @@ class OpenAIShimMessages { method: 'POST' as const, headers, body: serializedBody, - signal: options?.signal, }) + const fetchWithHeadersDeadline = async ( + url: string, + init: RequestInit, + ): Promise => { + const deadlineController = new AbortController() + const timeoutReason = new ResponseHeadersTimeoutError( + apiTimeoutMs, + redactUrlForDiagnostics(url), + ) + const { signal, cleanupAfterHeaders, cleanup } = combineRequestSignals( + options?.signal, + deadlineController.signal, + ) + const timer = setTimeout( + () => deadlineController.abort(timeoutReason), + apiTimeoutMs, + ) + timer.unref?.() + + let headersReceived = false + try { + const response = await fetchWithProxyRetry(url, { ...init, signal }) + headersReceived = true + return response + } catch (error) { + if (options?.signal?.aborted) { + throw preserveCallerAbortError(error, options.signal) + } + if ( + deadlineController.signal.aborted && + deadlineController.signal.reason === timeoutReason + ) { + throw timeoutReason + } + throw error + } finally { + clearTimeout(timer) + if (headersReceived) { + cleanupAfterHeaders() + } else { + cleanup() + } + } + } + const maxSelfHealAttempts = isLocal ? localRetryBaseUrls.length + 1 : 0 const credentialPoolAttempts = credentialPool?.size ?? 1 - const maxAttempts = + const maxAttempts = Math.max( + 2, Math.max(isGithub ? GITHUB_429_MAX_RETRIES : 1, credentialPoolAttempts) + - maxSelfHealAttempts + maxSelfHealAttempts, + ) const throwClassifiedTransportError = ( error: unknown, @@ -4602,7 +4729,7 @@ class OpenAIShimMessages { preclassifiedFailure?: ReturnType, ): never => { if (options?.signal?.aborted) { - throw error + throw preserveCallerAbortError(error, options.signal) } const failure = @@ -4695,11 +4822,16 @@ class OpenAIShimMessages { } const headers = await buildHeadersForAttempt(credentialLease) try { - response = await fetchWithProxyRetry( + response = await fetchWithHeadersDeadline( requestUrl, buildFetchInit(headers), ) } catch (error) { + if (options?.signal?.aborted) { + throw preserveCallerAbortError(error, options.signal) + } + const isResponseHeadersTimeout = + error instanceof ResponseHeadersTimeoutError const isAbortError = options?.signal?.aborted === true || (typeof DOMException !== 'undefined' && @@ -4710,7 +4842,7 @@ class OpenAIShimMessages { 'name' in error && error.name === 'AbortError') - if (isAbortError) { + if (!isResponseHeadersTimeout && isAbortError) { throw error } @@ -4718,6 +4850,14 @@ class OpenAIShimMessages { url: requestUrl, }) + if ( + isResponseHeadersTimeout && + failure.retryable && + attempt < maxAttempts - 1 + ) { + continue + } + if ( isLocal && failure.category === 'localhost_resolution_failed' && @@ -4822,14 +4962,26 @@ class OpenAIShimMessages { let responsesResponse!: Response try { - responsesResponse = await fetchWithProxyRetry(responsesUrl, { + responsesResponse = await fetchWithHeadersDeadline(responsesUrl, { method: 'POST', headers, body: stableStringifyJson(responsesBody), - signal: options?.signal, }) } catch (error) { - throwClassifiedTransportError(error, responsesUrl) + if (options?.signal?.aborted) { + throw preserveCallerAbortError(error, options.signal) + } + const failure = classifyOpenAINetworkFailure(error, { + url: responsesUrl, + }) + if ( + error instanceof ResponseHeadersTimeoutError && + failure.retryable && + attempt < maxAttempts - 1 + ) { + continue + } + throwClassifiedTransportError(error, responsesUrl, failure) } if (responsesResponse.ok) { @@ -5060,6 +5212,7 @@ export function createOpenAIShimClient(options: { // Test-only surface (same pattern as WebSearchTool's __test export). export const __test = { convertMessages, + getApiTimeoutMs, getStreamIdleTimeoutMs, readWithIdleTimeout, StreamIdleTimeoutError, From 8d80665a0c70007bda7b256ef2b1e54561e49cf2 Mon Sep 17 00:00:00 2001 From: chioarub Date: Sat, 11 Jul 2026 09:27:55 +0300 Subject: [PATCH 02/17] test(api): cover Copilot responses fallback deadlines --- src/services/api/openaiShim.test.ts | 131 ++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 5634ab2826..48d26c2684 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -406,6 +406,33 @@ function makeChatCompletionResponse(model: string): Response { ) } +function makeGithubChatFallbackResponse(): Response { + return new Response( + JSON.stringify({ + error: { message: '/chat/completions is not accessible for this model' }, + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) +} + +function makeResponsesApiResponse(model: string): Response { + return new Response( + JSON.stringify({ + id: 'resp-fallback-test', + model, + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok' }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) +} + function pendingFetchUntilAbort( init: RequestInit | undefined, ): Promise { @@ -9731,6 +9758,110 @@ function makeCodexSseResponse(responseData: Record): Response { return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`]) } +test('GitHub Copilot responses fallback retries after a pre-header timeout', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'test-token' + process.env.API_TIMEOUT_MS = '20' + const requestUrls: string[] = [] + + globalThis.fetch = (async (input, init) => { + const url = String(input) + requestUrls.push(url) + if (url.endsWith('/chat/completions')) { + return makeGithubChatFallbackResponse() + } + if (requestUrls.length === 2) { + return pendingFetchUntilAbort(init) + } + return makeResponsesApiResponse('gpt-4') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const response = await waitForPromise( + client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + 500, + 'GitHub responses fallback timeout retry did not settle', + ) + + expect(response).toBeDefined() + expect(requestUrls).toEqual([ + 'https://api.githubcopilot.com/chat/completions', + 'https://api.githubcopilot.com/responses', + 'https://api.githubcopilot.com/chat/completions', + 'https://api.githubcopilot.com/responses', + ]) +}) + +test('GitHub Copilot responses fallback preserves caller abort without retrying', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'test-token' + process.env.API_TIMEOUT_MS = '200' + let fetchCalls = 0 + + globalThis.fetch = (async (input, init) => { + fetchCalls++ + if (String(input).endsWith('/chat/completions')) { + return makeGithubChatFallbackResponse() + } + return pendingFetchUntilAbort(init) + }) as unknown as FetchType + + const caller = new AbortController() + const callerReason = new DOMException('Cancelled by user', 'AbortError') + const client = createOpenAIShimClient({}) as OpenAIShimClient + const request = client.beta.messages.create( + { + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }, + { signal: caller.signal }, + ) + setTimeout(() => caller.abort(callerReason), 10) + + await expect( + waitForPromise(request, 500, 'GitHub responses fallback abort did not settle'), + ).rejects.toBe(callerReason) + expect(fetchCalls).toBe(2) +}) + +test('GitHub Copilot responses fallback does not retry non-retryable HTTP failures', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'test-token' + let fetchCalls = 0 + + globalThis.fetch = (async input => { + fetchCalls++ + if (String(input).endsWith('/chat/completions')) { + return makeGithubChatFallbackResponse() + } + return new Response( + JSON.stringify({ error: { message: 'invalid token' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toMatchObject({ status: 401 }) + expect(fetchCalls).toBe(2) +}) + test('GitHub Copilot 401 chat_completions retries with refreshed token', async () => { const realModule = realGithubModelsCredentials try { From d458dcb6cdc1d09173563d411b2e02e8f3fede01 Mon Sep 17 00:00:00 2001 From: chioarub Date: Mon, 13 Jul 2026 02:33:16 +0300 Subject: [PATCH 03/17] fix(api): redact secrets in timeout URL paths --- src/services/api/openaiShim.test.ts | 7 ++++++- src/services/api/openaiShim.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 48d26c2684..4bf7ff5c03 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -6899,8 +6899,11 @@ test('propagates AbortError without wrapping it as transport failure', async () test('classifies a pre-header API timeout as a retryable transport failure', async () => { process.env.API_TIMEOUT_MS = '20' + const pathSecret = 'route/key+AbC123' + const encodedPathSecret = encodeURIComponent(pathSecret) + process.env.OPENAI_API_KEY = pathSecret process.env.OPENAI_BASE_URL = - 'https://user:password@slow.example.test/v1?token=secret' + `https://user:password@slow.example.test/v1/${encodedPathSecret}?token=secret` let fetchCalls = 0 globalThis.fetch = (async (_input, init) => { fetchCalls++ @@ -6940,6 +6943,8 @@ test('classifies a pre-header API timeout as a retryable transport failure', asy expect(error.message).toContain('openai_category=request_timeout') expect(error.message).not.toContain('password') expect(error.message).not.toContain('token=secret') + expect(error.message).not.toContain(pathSecret) + expect(error.message).not.toContain(encodedPathSecret) expect(fetchCalls).toBe(2) }) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 81f548cf3c..3738b3d697 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -97,6 +97,7 @@ import { } from './openaiErrorClassification.js' import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js' import { redactSecretValueForDisplay, type SecretValueSource } from '../../utils/providerProfile.js' +import { redactSecretSubstringsForDisplay } from '../../utils/providerSecrets.js' import { redactUrlForDisplay, shouldRedactUrlQueryParam, @@ -491,10 +492,32 @@ function formatRetryAfterHint(response: Response): string { } function redactUrlForDiagnostics(url: string): string { - const redacted = redactUrlForDisplay(url) + let redacted = redactUrlForDisplay(url) + try { + const parsed = new URL(redacted) + const decodedPathname = decodeURIComponent(parsed.pathname) + const redactedPathname = + redactSecretSubstringsForDisplay( + decodedPathname, + process.env as SecretValueSource, + ) ?? decodedPathname + if (redactedPathname !== decodedPathname) { + parsed.pathname = redactedPathname + redacted = parsed.toString() + } + } catch { + // Keep the URL-level redaction when the pathname is not decodable. + } + const redactedSubstrings = + redactSecretSubstringsForDisplay( + redacted, + process.env as SecretValueSource, + ) ?? redacted return ( - redactSecretValueForDisplay(redacted, process.env as SecretValueSource) ?? - redacted + redactSecretValueForDisplay( + redactedSubstrings, + process.env as SecretValueSource, + ) ?? redactedSubstrings ) } From d2946834806672b061be8cb2bb94d6bbcba20f70 Mon Sep 17 00:00:00 2001 From: chioarub Date: Mon, 13 Jul 2026 10:12:35 +0300 Subject: [PATCH 04/17] fix(api): harden Copilot response deadlines --- src/services/api/codexShim.ts | 3 +- src/services/api/openaiShim.test.ts | 99 ++++++++- src/services/api/openaiShim.ts | 330 +++++++++++++++++++--------- 3 files changed, 325 insertions(+), 107 deletions(-) diff --git a/src/services/api/codexShim.ts b/src/services/api/codexShim.ts index 09e0cc0f80..f97ef951e0 100644 --- a/src/services/api/codexShim.ts +++ b/src/services/api/codexShim.ts @@ -600,6 +600,7 @@ export async function performCodexRequest(options: { params: ShimCreateParams defaultHeaders: Record signal?: AbortSignal + fetcher?: typeof fetchWithProxyRetry }): Promise { const compressedMessages = compressToolHistory( options.params.messages as Array<{ @@ -678,7 +679,7 @@ export async function performCodexRequest(options: { } headers.originator ??= 'openclaude' - const response = await fetchWithProxyRetry( + const response = await (options.fetcher ?? fetchWithProxyRetry)( `${options.request.baseUrl}/responses`, { method: 'POST', diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 4bf7ff5c03..124b0a8f31 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -6901,9 +6901,17 @@ test('classifies a pre-header API timeout as a retryable transport failure', asy process.env.API_TIMEOUT_MS = '20' const pathSecret = 'route/key+AbC123' const encodedPathSecret = encodeURIComponent(pathSecret) + const doubleEncodedPathSecret = encodeURIComponent(encodedPathSecret) + const escapeLookingSecret = 'abc%2FdefLONG' + const encodedEscapeLookingSecret = encodeURIComponent(escapeLookingSecret) + const decodedEscapeLookingSecret = decodeURIComponent(escapeLookingSecret) + const malformedUtf8Secret = 'éSECRET_VALUE_123' + const encodedMalformedUtf8Secret = encodeURIComponent(malformedUtf8Secret) process.env.OPENAI_API_KEY = pathSecret + process.env.OPENROUTER_API_KEY = escapeLookingSecret + process.env.DEEPSEEK_API_KEY = malformedUtf8Secret process.env.OPENAI_BASE_URL = - `https://user:password@slow.example.test/v1/${encodedPathSecret}?token=secret` + `https://user:password@slow.example.test/v1/invalid%ZZ/${doubleEncodedPathSecret}/${encodedEscapeLookingSecret}/%E0%A4${encodedMalformedUtf8Secret}?token=secret` let fetchCalls = 0 globalThis.fetch = (async (_input, init) => { fetchCalls++ @@ -6945,6 +6953,12 @@ test('classifies a pre-header API timeout as a retryable transport failure', asy expect(error.message).not.toContain('token=secret') expect(error.message).not.toContain(pathSecret) expect(error.message).not.toContain(encodedPathSecret) + expect(error.message).not.toContain(doubleEncodedPathSecret) + expect(error.message).not.toContain(escapeLookingSecret) + expect(error.message).not.toContain(encodedEscapeLookingSecret) + expect(error.message).not.toContain(decodedEscapeLookingSecret) + expect(error.message).not.toContain(malformedUtf8Secret) + expect(error.message).not.toContain(encodedMalformedUtf8Secret) expect(fetchCalls).toBe(2) }) @@ -9763,6 +9777,62 @@ function makeCodexSseResponse(responseData: Record): Response { return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`]) } +test('GitHub Copilot codex responses transport retries after a pre-header timeout', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'test-token' + process.env.API_TIMEOUT_MS = '20' + let fetchCalls = 0 + const requestUrls: string[] = [] + + globalThis.fetch = (async (input, init) => { + fetchCalls++ + requestUrls.push(String(input)) + if (fetchCalls === 1) return pendingFetchUntilAbort(init) + return makeCodexSseResponse({ + response: { + id: 'resp_timeout_retry', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'ok' }], + }, + ], + model: 'gpt-5', + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }) + }) as unknown as FetchType + + const safety = new AbortController() + const safetyTimer = setTimeout(() => safety.abort(), 500) + const client = createOpenAIShimClient({}) as OpenAIShimClient + try { + const response = await waitForPromise( + client.beta.messages.create( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }, + { signal: safety.signal }, + ), + 750, + 'GitHub codex responses timeout retry did not settle', + ) + + expect(response).toBeDefined() + expect(fetchCalls).toBe(2) + expect(requestUrls).toEqual([ + 'https://api.githubcopilot.com/responses', + 'https://api.githubcopilot.com/responses', + ]) + } finally { + clearTimeout(safetyTimer) + } +}) + test('GitHub Copilot responses fallback retries after a pre-header timeout', async () => { process.env.CLAUDE_CODE_USE_GITHUB = '1' process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' @@ -9838,6 +9908,33 @@ test('GitHub Copilot responses fallback preserves caller abort without retrying' expect(fetchCalls).toBe(2) }) +test('GitHub Copilot responses fallback preserves non-caller transport aborts', async () => { + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' + process.env.OPENAI_API_KEY = 'test-token' + let fetchCalls = 0 + const transportAbort = new DOMException('Proxy aborted request', 'AbortError') + + globalThis.fetch = (async input => { + fetchCalls++ + if (String(input).endsWith('/chat/completions')) { + return makeGithubChatFallbackResponse() + } + throw transportAbort + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await expect( + client.beta.messages.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }), + ).rejects.toBe(transportAbort) + expect(fetchCalls).toBe(2) +}) + test('GitHub Copilot responses fallback does not retry non-retryable HTTP failures', async () => { process.env.CLAUDE_CODE_USE_GITHUB = '1' process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 3738b3d697..3ddb353ace 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -167,6 +167,18 @@ function preserveCallerAbortError( : error } +function isAbortError(error: unknown): boolean { + return ( + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError') + ) +} + function createStreamAbortError(): DOMException { return new DOMException('Aborted', 'AbortError') } @@ -277,6 +289,55 @@ function combineRequestSignals( return { signal: combined.signal, cleanupAfterHeaders, cleanup } } +async function fetchWithHeadersDeadline( + url: string, + init: RequestInit, + options: { + callerSignal?: AbortSignal + timeoutMs: number + }, +): Promise { + const deadlineController = new AbortController() + const timeoutReason = new ResponseHeadersTimeoutError( + options.timeoutMs, + redactUrlForDiagnostics(url), + ) + const { signal, cleanupAfterHeaders, cleanup } = combineRequestSignals( + options.callerSignal, + deadlineController.signal, + ) + const timer = setTimeout( + () => deadlineController.abort(timeoutReason), + options.timeoutMs, + ) + timer.unref?.() + + let headersReceived = false + try { + const response = await fetchWithProxyRetry(url, { ...init, signal }) + headersReceived = true + return response + } catch (error) { + if (options.callerSignal?.aborted) { + throw preserveCallerAbortError(error, options.callerSignal) + } + if ( + deadlineController.signal.aborted && + deadlineController.signal.reason === timeoutReason + ) { + throw timeoutReason + } + throw error + } finally { + clearTimeout(timer) + if (headersReceived) { + cleanupAfterHeaders() + } else { + cleanup() + } + } +} + async function readWithIdleTimeout( reader: ReadableStreamDefaultReader, timeoutMs: number, @@ -491,17 +552,62 @@ function formatRetryAfterHint(response: Response): string { return ra ? ` (Retry-After: ${ra})` : '' } +function decodeValidPercentRun(encoded: string): string { + const escapes = encoded.match(/%[0-9A-Fa-f]{2}/g) + if (!escapes) return encoded + + let decoded = '' + let offset = 0 + while (offset < escapes.length) { + let decodedPrefix: string | undefined + let prefixLength = escapes.length - offset + while (prefixLength > 0) { + try { + decodedPrefix = decodeURIComponent( + escapes.slice(offset, offset + prefixLength).join(''), + ) + break + } catch { + prefixLength-- + } + } + + if (decodedPrefix === undefined) { + decoded += escapes[offset] + offset++ + continue + } + + decoded += decodedPrefix + offset += prefixLength + } + return decoded +} + +function decodeValidUrlEscapesOnce(value: string): string { + return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, decodeValidPercentRun) +} + +function redactDecodedPathSecrets(value: string): string { + let decoded = value + while (true) { + const redacted = + redactSecretSubstringsForDisplay( + decoded, + process.env as SecretValueSource, + ) ?? decoded + const next = decodeValidUrlEscapesOnce(redacted) + if (next === redacted) return redacted + decoded = next + } +} + function redactUrlForDiagnostics(url: string): string { let redacted = redactUrlForDisplay(url) try { const parsed = new URL(redacted) - const decodedPathname = decodeURIComponent(parsed.pathname) - const redactedPathname = - redactSecretSubstringsForDisplay( - decodedPathname, - process.env as SecretValueSource, - ) ?? decodedPathname - if (redactedPathname !== decodedPathname) { + const redactedPathname = redactDecodedPathSecrets(parsed.pathname) + if (redactedPathname !== parsed.pathname) { parsed.pathname = redactedPathname redacted = parsed.toString() } @@ -525,6 +631,40 @@ function redactUrlsInMessage(message: string): string { return message.replace(/https?:\/\/\S+/g, match => redactUrlForDiagnostics(match)) } +function createClassifiedTransportError( + error: unknown, + requestUrl: string, + model: string, + preclassifiedFailure?: ReturnType, +) { + const failure = + preclassifiedFailure ?? + classifyOpenAINetworkFailure(error, { + url: requestUrl, + }) + const redactedUrl = redactUrlForDiagnostics(requestUrl) + const safeMessage = + redactSecretValueForDisplay( + redactUrlsInMessage(failure.message), + process.env as SecretValueSource, + ) || 'Request failed' + + logForDebugging( + `[OpenAIShim] transport failure category=${failure.category} retryable=${failure.retryable} code=${failure.code ?? 'unknown'} method=POST url=${redactedUrl} model=${model} message=${safeMessage}`, + { level: 'warn' }, + ) + + return APIError.generate( + 0, + undefined, + buildOpenAICompatibilityErrorMessage( + `OpenAI API transport error: ${safeMessage}${failure.code ? ` (code=${failure.code})` : ''}`, + failure, + ), + new Headers(), + ) +} + function sleepMs(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } @@ -3782,6 +3922,8 @@ class OpenAIShimMessages { const isGithubWithCodexTransport = isGithubCopilotEndpoint && request.transport === 'codex_responses' if (isGithubWithCodexTransport) { + const apiTimeoutMs = getApiTimeoutMs() + const responsesUrl = `${request.baseUrl}/responses` let didRefreshCopilotCodexToken = false let refreshedCopilotCodexToken: string | undefined for (let attempt = 0; attempt < 2; attempt++) { @@ -3793,20 +3935,55 @@ class OpenAIShimMessages { } try { - return await performCodexRequest({ - request, - credentials: { - apiKey, - source: 'env', - }, - params, - defaultHeaders: { - ...this.defaultHeaders, - ...filterAnthropicHeaders(options?.headers), - ...COPILOT_HEADERS, - }, - signal: options?.signal, - }) + for (let timeoutAttempt = 0; timeoutAttempt < 2; timeoutAttempt++) { + try { + return await performCodexRequest({ + request, + credentials: { + apiKey, + source: 'env', + }, + params, + defaultHeaders: { + ...this.defaultHeaders, + ...filterAnthropicHeaders(options?.headers), + ...COPILOT_HEADERS, + }, + signal: options?.signal, + fetcher: (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + return fetchWithHeadersDeadline(url, init ?? {}, { + callerSignal: options?.signal, + timeoutMs: apiTimeoutMs, + }) + }, + }) + } catch (error) { + if (options?.signal?.aborted) { + throw preserveCallerAbortError(error, options.signal) + } + if (error instanceof ResponseHeadersTimeoutError) { + const failure = classifyOpenAINetworkFailure(error, { + url: responsesUrl, + }) + if (failure.retryable && timeoutAttempt === 0) { + continue + } + throw createClassifiedTransportError( + error, + responsesUrl, + request.resolvedModel, + failure, + ) + } + throw error + } + } } catch (error) { if ( !didRefreshCopilotCodexToken && @@ -4691,50 +4868,14 @@ class OpenAIShimMessages { body: serializedBody, }) - const fetchWithHeadersDeadline = async ( + const fetchAttemptWithHeadersDeadline = ( url: string, init: RequestInit, - ): Promise => { - const deadlineController = new AbortController() - const timeoutReason = new ResponseHeadersTimeoutError( - apiTimeoutMs, - redactUrlForDiagnostics(url), - ) - const { signal, cleanupAfterHeaders, cleanup } = combineRequestSignals( - options?.signal, - deadlineController.signal, - ) - const timer = setTimeout( - () => deadlineController.abort(timeoutReason), - apiTimeoutMs, - ) - timer.unref?.() - - let headersReceived = false - try { - const response = await fetchWithProxyRetry(url, { ...init, signal }) - headersReceived = true - return response - } catch (error) { - if (options?.signal?.aborted) { - throw preserveCallerAbortError(error, options.signal) - } - if ( - deadlineController.signal.aborted && - deadlineController.signal.reason === timeoutReason - ) { - throw timeoutReason - } - throw error - } finally { - clearTimeout(timer) - if (headersReceived) { - cleanupAfterHeaders() - } else { - cleanup() - } - } - } + ): Promise => + fetchWithHeadersDeadline(url, init, { + callerSignal: options?.signal, + timeoutMs: apiTimeoutMs, + }) const maxSelfHealAttempts = isLocal ? localRetryBaseUrls.length + 1 @@ -4755,31 +4896,11 @@ class OpenAIShimMessages { throw preserveCallerAbortError(error, options.signal) } - const failure = - preclassifiedFailure ?? - classifyOpenAINetworkFailure(error, { - url: requestUrl, - }) - const redactedUrl = redactUrlForDiagnostics(requestUrl) - const safeMessage = - redactSecretValueForDisplay( - redactUrlsInMessage(failure.message), - process.env as SecretValueSource, - ) || 'Request failed' - - logForDebugging( - `[OpenAIShim] transport failure category=${failure.category} retryable=${failure.retryable} code=${failure.code ?? 'unknown'} method=POST url=${redactedUrl} model=${request.resolvedModel} message=${safeMessage}`, - { level: 'warn' }, - ) - - throw APIError.generate( - 0, - undefined, - buildOpenAICompatibilityErrorMessage( - `OpenAI API transport error: ${safeMessage}${failure.code ? ` (code=${failure.code})` : ''}`, - failure, - ), - new Headers(), + throw createClassifiedTransportError( + error, + requestUrl, + request.resolvedModel, + preclassifiedFailure, ) } @@ -4845,7 +4966,7 @@ class OpenAIShimMessages { } const headers = await buildHeadersForAttempt(credentialLease) try { - response = await fetchWithHeadersDeadline( + response = await fetchAttemptWithHeadersDeadline( requestUrl, buildFetchInit(headers), ) @@ -4855,17 +4976,7 @@ class OpenAIShimMessages { } const isResponseHeadersTimeout = error instanceof ResponseHeadersTimeoutError - const isAbortError = - options?.signal?.aborted === true || - (typeof DOMException !== 'undefined' && - error instanceof DOMException && - error.name === 'AbortError') || - (typeof error === 'object' && - error !== null && - 'name' in error && - error.name === 'AbortError') - - if (!isResponseHeadersTimeout && isAbortError) { + if (!isResponseHeadersTimeout && isAbortError(error)) { throw error } @@ -4985,15 +5096,24 @@ class OpenAIShimMessages { let responsesResponse!: Response try { - responsesResponse = await fetchWithHeadersDeadline(responsesUrl, { - method: 'POST', - headers, - body: stableStringifyJson(responsesBody), - }) + responsesResponse = await fetchAttemptWithHeadersDeadline( + responsesUrl, + { + method: 'POST', + headers, + body: stableStringifyJson(responsesBody), + }, + ) } catch (error) { if (options?.signal?.aborted) { throw preserveCallerAbortError(error, options.signal) } + if ( + !(error instanceof ResponseHeadersTimeoutError) && + isAbortError(error) + ) { + throw error + } const failure = classifyOpenAINetworkFailure(error, { url: responsesUrl, }) From 8fa2e767c228db5d0b5dde3ba4c5eb22fc63b02a Mon Sep 17 00:00:00 2001 From: chioarub Date: Mon, 13 Jul 2026 23:03:17 +0300 Subject: [PATCH 05/17] fix(api): prevent header-timeout request replay --- .env.example | 8 +- docs/advanced-setup.md | 1 + src/services/api/openaiErrorClassification.ts | 22 +++ src/services/api/openaiShim.test.ts | 146 ++++++++---------- src/services/api/openaiShim.ts | 130 ++++++++-------- src/services/api/withRetry.test.ts | 40 +++++ src/services/api/withRetry.ts | 5 + 7 files changed, 208 insertions(+), 144 deletions(-) diff --git a/.env.example b/.env.example index e9a6df1421..a8eaa1fc5b 100644 --- a/.env.example +++ b/.env.example @@ -417,8 +417,12 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # Turn on whichever audience you're debugging; both can run together. # OPENCLAUDE_LOG_TOKEN_USAGE=verbose -# Custom timeout for API requests in milliseconds (default: varies) -# API_TIMEOUT_MS=60000 +# Time-to-response-headers deadline for OpenAI-compatible API requests +# in milliseconds (default: 600000, or 10 minutes). Use a safe positive +# integer; invalid values use the default and values above 2147483647 are capped. +# This runtime setting must be exported from your shell or launcher; the +# provider env-file loader intentionally ignores runtime/debug knobs. +# API_TIMEOUT_MS=600000 # Enable debug logging # CLAUDE_DEBUG=1 diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 534b7078fe..1c0c5c80e3 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -417,6 +417,7 @@ already present as dev dependencies, so source/dev builds need no extra steps. | `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` | | `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` | | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | +| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for OpenAI-compatible requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher. | | `OPENCLAUDE_OLLAMA_NUM_CTX` | Ollama only | Request-level Ollama context window. Defaults to `32768`; set a larger value for longer same-session history if your model and hardware can handle it. | | `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. | | `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` | No | JSON map of OpenAI-compatible model names to max output tokens, such as `{"custom-model":32768}`. Use this when a custom provider does not expose output-limit metadata from `/v1/models`. | diff --git a/src/services/api/openaiErrorClassification.ts b/src/services/api/openaiErrorClassification.ts index 3413a6a57d..54fba07e9c 100644 --- a/src/services/api/openaiErrorClassification.ts +++ b/src/services/api/openaiErrorClassification.ts @@ -26,6 +26,28 @@ export type OpenAICompatibilityFailure = { requestUrl?: string } +const NON_REPLAYABLE_OPENAI_REQUEST = Symbol.for( + 'openclaude.openai.nonReplayableRequest', +) + +export function markOpenAIRequestNonReplayable(error: T): T { + Object.defineProperty(error, NON_REPLAYABLE_OPENAI_REQUEST, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }) + return error +} + +export function isOpenAIRequestNonReplayable(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + Reflect.get(error, NON_REPLAYABLE_OPENAI_REQUEST) === true + ) +} + const OPENAI_CATEGORY_MARKER_PREFIX = '[openai_category=' const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 124b0a8f31..c046b5f2a4 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -9,6 +9,7 @@ import { getAssistantMessageFromError, OPENCODE_GO_FREE_LIMIT_ERROR_MESSAGE, } from './errors.ts' +import { isOpenAIRequestNonReplayable } from './openaiErrorClassification.ts' import { createOpenAIShimClient, hasMistralApiHost } from './openaiShim.ts' import * as realCodexShim from './codexShim.js' import * as realGithubModelsCredentials from '../../utils/githubModelsCredentials.js' @@ -6897,7 +6898,7 @@ test('propagates AbortError without wrapping it as transport failure', async () ).rejects.toBe(abortError) }) -test('classifies a pre-header API timeout as a retryable transport failure', async () => { +test('classifies a pre-header API timeout without replaying the request', async () => { process.env.API_TIMEOUT_MS = '20' const pathSecret = 'route/key+AbC123' const encodedPathSecret = encodeURIComponent(pathSecret) @@ -6913,9 +6914,24 @@ test('classifies a pre-header API timeout as a retryable transport failure', asy process.env.OPENAI_BASE_URL = `https://user:password@slow.example.test/v1/invalid%ZZ/${doubleEncodedPathSecret}/${encodedEscapeLookingSecret}/%E0%A4${encodedMalformedUtf8Secret}?token=secret` let fetchCalls = 0 + let completedGenerations = 0 + const receivedBodies: string[] = [] globalThis.fetch = (async (_input, init) => { fetchCalls++ - return pendingFetchUntilAbort(init) + receivedBodies.push(String(init?.body)) + return new Promise((resolve, reject) => { + setTimeout(() => { + completedGenerations++ + resolve(makeChatCompletionResponse('gpt-4o-mini')) + }, 50) + const signal = init?.signal + if (!signal) return + const rejectFromAbort = () => { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) + } + signal.addEventListener('abort', rejectFromAbort, { once: true }) + if (signal.aborted) rejectFromAbort() + }) }) as unknown as FetchType const safety = new AbortController() @@ -6942,10 +6958,12 @@ test('classifies a pre-header API timeout as a retryable transport failure', asy } finally { clearTimeout(safetyTimer) } + await new Promise(resolve => setTimeout(resolve, 60)) expect(caught).toBeDefined() const error = caught as Error & { constructor: { name: string } } expect(error.constructor.name).toBe('APIConnectionError') + expect(isOpenAIRequestNonReplayable(error)).toBe(true) expect(error.message).toContain('no response headers within 20ms (API_TIMEOUT_MS)') expect(error.message).toContain('slow.example.test') expect(error.message).toContain('openai_category=request_timeout') @@ -6959,7 +6977,9 @@ test('classifies a pre-header API timeout as a retryable transport failure', asy expect(error.message).not.toContain(decodedEscapeLookingSecret) expect(error.message).not.toContain(malformedUtf8Secret) expect(error.message).not.toContain(encodedMalformedUtf8Secret) - expect(fetchCalls).toBe(2) + expect(fetchCalls).toBe(1) + expect(receivedBodies).toHaveLength(1) + expect(completedGenerations).toBe(1) }) test('preserves caller cancellation while waiting for response headers without retrying', async () => { @@ -7138,40 +7158,6 @@ test('disarms the API timeout after headers arrive while the body keeps streamin expect(fetchSignals[0].aborted).toBe(false) }) -test('retries after a pre-header timeout and succeeds on the next attempt', async () => { - process.env.API_TIMEOUT_MS = '20' - let fetchCalls = 0 - globalThis.fetch = (async (_input, init) => { - fetchCalls++ - if (fetchCalls === 1) return pendingFetchUntilAbort(init) - return makeChatCompletionResponse('gpt-4o-mini') - }) as unknown as FetchType - - const safety = new AbortController() - const safetyTimer = setTimeout(() => safety.abort(), 500) - const client = createOpenAIShimClient({}) as OpenAIShimClient - try { - const response = await waitForPromise( - client.beta.messages.create( - { - model: 'gpt-4o-mini', - messages: [{ role: 'user', content: 'hello' }], - max_tokens: 64, - stream: false, - }, - { signal: safety.signal }, - ), - 750, - 'timeout retry did not settle', - ) - - expect(response).toBeDefined() - expect(fetchCalls).toBe(2) - } finally { - clearTimeout(safetyTimer) - } -}) - test('classifies chat-completions endpoint 404 failures with endpoint_not_found marker', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434' @@ -9777,7 +9763,7 @@ function makeCodexSseResponse(responseData: Record): Response { return makeSseResponse([`event: response.completed\ndata: ${data}\n\n`]) } -test('GitHub Copilot codex responses transport retries after a pre-header timeout', async () => { +test('GitHub Copilot codex responses transport does not replay after a pre-header timeout', async () => { process.env.CLAUDE_CODE_USE_GITHUB = '1' process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' process.env.OPENAI_API_KEY = 'test-token' @@ -9788,27 +9774,15 @@ test('GitHub Copilot codex responses transport retries after a pre-header timeou globalThis.fetch = (async (input, init) => { fetchCalls++ requestUrls.push(String(input)) - if (fetchCalls === 1) return pendingFetchUntilAbort(init) - return makeCodexSseResponse({ - response: { - id: 'resp_timeout_retry', - output: [ - { - type: 'message', - content: [{ type: 'output_text', text: 'ok' }], - }, - ], - model: 'gpt-5', - usage: { input_tokens: 10, output_tokens: 5 }, - }, - }) + return pendingFetchUntilAbort(init) }) as unknown as FetchType const safety = new AbortController() const safetyTimer = setTimeout(() => safety.abort(), 500) const client = createOpenAIShimClient({}) as OpenAIShimClient + let caught: unknown try { - const response = await waitForPromise( + await waitForPromise( client.beta.messages.create( { model: 'gpt-5', @@ -9819,21 +9793,25 @@ test('GitHub Copilot codex responses transport retries after a pre-header timeou { signal: safety.signal }, ), 750, - 'GitHub codex responses timeout retry did not settle', + 'GitHub codex responses timeout did not settle', ) - - expect(response).toBeDefined() - expect(fetchCalls).toBe(2) - expect(requestUrls).toEqual([ - 'https://api.githubcopilot.com/responses', - 'https://api.githubcopilot.com/responses', - ]) + } catch (error) { + caught = error } finally { clearTimeout(safetyTimer) } + + expect(caught).toBeDefined() + const error = caught as Error & { constructor: { name: string } } + expect(error.constructor.name).toBe('APIConnectionError') + expect(isOpenAIRequestNonReplayable(error)).toBe(true) + expect(fetchCalls).toBe(1) + expect(requestUrls).toEqual([ + 'https://api.githubcopilot.com/responses', + ]) }) -test('GitHub Copilot responses fallback retries after a pre-header timeout', async () => { +test('GitHub Copilot responses fallback does not replay after a pre-header timeout', async () => { process.env.CLAUDE_CODE_USE_GITHUB = '1' process.env.OPENAI_BASE_URL = 'https://api.githubcopilot.com' process.env.OPENAI_API_KEY = 'test-token' @@ -9846,30 +9824,40 @@ test('GitHub Copilot responses fallback retries after a pre-header timeout', asy if (url.endsWith('/chat/completions')) { return makeGithubChatFallbackResponse() } - if (requestUrls.length === 2) { - return pendingFetchUntilAbort(init) - } - return makeResponsesApiResponse('gpt-4') + return pendingFetchUntilAbort(init) }) as unknown as FetchType + const safety = new AbortController() + const safetyTimer = setTimeout(() => safety.abort(), 500) const client = createOpenAIShimClient({}) as OpenAIShimClient - const response = await waitForPromise( - client.beta.messages.create({ - model: 'gpt-4', - messages: [{ role: 'user', content: 'hello' }], - max_tokens: 32, - stream: false, - }), - 500, - 'GitHub responses fallback timeout retry did not settle', - ) + let caught: unknown + try { + await waitForPromise( + client.beta.messages.create( + { + model: 'gpt-4', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }, + { signal: safety.signal }, + ), + 750, + 'GitHub responses fallback timeout did not settle', + ) + } catch (error) { + caught = error + } finally { + clearTimeout(safetyTimer) + } - expect(response).toBeDefined() + expect(caught).toBeDefined() + const error = caught as Error & { constructor: { name: string } } + expect(error.constructor.name).toBe('APIConnectionError') + expect(isOpenAIRequestNonReplayable(error)).toBe(true) expect(requestUrls).toEqual([ 'https://api.githubcopilot.com/chat/completions', 'https://api.githubcopilot.com/responses', - 'https://api.githubcopilot.com/chat/completions', - 'https://api.githubcopilot.com/responses', ]) }) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 3ddb353ace..1afb3bb255 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -94,6 +94,7 @@ import { buildOpenAICompatibilityErrorMessage, classifyOpenAIHttpFailure, classifyOpenAINetworkFailure, + markOpenAIRequestNonReplayable, } from './openaiErrorClassification.js' import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js' import { redactSecretValueForDisplay, type SecretValueSource } from '../../utils/providerProfile.js' @@ -654,7 +655,7 @@ function createClassifiedTransportError( { level: 'warn' }, ) - return APIError.generate( + const apiError = APIError.generate( 0, undefined, buildOpenAICompatibilityErrorMessage( @@ -663,6 +664,9 @@ function createClassifiedTransportError( ), new Headers(), ) + return failure.retryable + ? apiError + : markOpenAIRequestNonReplayable(apiError) } function sleepMs(ms: number): Promise { @@ -3935,54 +3939,52 @@ class OpenAIShimMessages { } try { - for (let timeoutAttempt = 0; timeoutAttempt < 2; timeoutAttempt++) { - try { - return await performCodexRequest({ - request, - credentials: { - apiKey, - source: 'env', - }, - params, - defaultHeaders: { - ...this.defaultHeaders, - ...filterAnthropicHeaders(options?.headers), - ...COPILOT_HEADERS, - }, - signal: options?.signal, - fetcher: (input, init) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url - return fetchWithHeadersDeadline(url, init ?? {}, { - callerSignal: options?.signal, - timeoutMs: apiTimeoutMs, - }) - }, - }) - } catch (error) { - if (options?.signal?.aborted) { - throw preserveCallerAbortError(error, options.signal) - } - if (error instanceof ResponseHeadersTimeoutError) { - const failure = classifyOpenAINetworkFailure(error, { - url: responsesUrl, + try { + return await performCodexRequest({ + request, + credentials: { + apiKey, + source: 'env', + }, + params, + defaultHeaders: { + ...this.defaultHeaders, + ...filterAnthropicHeaders(options?.headers), + ...COPILOT_HEADERS, + }, + signal: options?.signal, + fetcher: (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + return fetchWithHeadersDeadline(url, init ?? {}, { + callerSignal: options?.signal, + timeoutMs: apiTimeoutMs, }) - if (failure.retryable && timeoutAttempt === 0) { - continue - } - throw createClassifiedTransportError( - error, - responsesUrl, - request.resolvedModel, - failure, - ) + }, + }) + } catch (error) { + if (options?.signal?.aborted) { + throw preserveCallerAbortError(error, options.signal) + } + if (error instanceof ResponseHeadersTimeoutError) { + const failure = { + ...classifyOpenAINetworkFailure(error, { + url: responsesUrl, + }), + retryable: false, } - throw error + throw createClassifiedTransportError( + error, + responsesUrl, + request.resolvedModel, + failure, + ) } + throw error } } catch (error) { if ( @@ -4980,27 +4982,26 @@ class OpenAIShimMessages { throw error } - const failure = classifyOpenAINetworkFailure(error, { + const classifiedFailure = classifyOpenAINetworkFailure(error, { url: requestUrl, }) - if ( - isResponseHeadersTimeout && - failure.retryable && - attempt < maxAttempts - 1 - ) { - continue + if (isResponseHeadersTimeout) { + throwClassifiedTransportError(error, requestUrl, { + ...classifiedFailure, + retryable: false, + }) } if ( isLocal && - failure.category === 'localhost_resolution_failed' && + classifiedFailure.category === 'localhost_resolution_failed' && promoteNextLocalBaseUrl('localhost_resolution_failed') ) { continue } - throwClassifiedTransportError(error, requestUrl, failure) + throwClassifiedTransportError(error, requestUrl, classifiedFailure) } // After the try/catch, response is guaranteed to be defined — the catch @@ -5114,17 +5115,20 @@ class OpenAIShimMessages { ) { throw error } - const failure = classifyOpenAINetworkFailure(error, { + const classifiedFailure = classifyOpenAINetworkFailure(error, { url: responsesUrl, }) - if ( - error instanceof ResponseHeadersTimeoutError && - failure.retryable && - attempt < maxAttempts - 1 - ) { - continue + if (error instanceof ResponseHeadersTimeoutError) { + throwClassifiedTransportError(error, responsesUrl, { + ...classifiedFailure, + retryable: false, + }) } - throwClassifiedTransportError(error, responsesUrl, failure) + throwClassifiedTransportError( + error, + responsesUrl, + classifiedFailure, + ) } if (responsesResponse.ok) { diff --git a/src/services/api/withRetry.test.ts b/src/services/api/withRetry.test.ts index c859ce1193..7134c61718 100644 --- a/src/services/api/withRetry.test.ts +++ b/src/services/api/withRetry.test.ts @@ -3,6 +3,7 @@ import type Anthropic from '@anthropic-ai/sdk' import { APIError, APIUserAbortError } from '@anthropic-ai/sdk' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import * as debugNs from '../../utils/debug.js' +import { markOpenAIRequestNonReplayable } from './openaiErrorClassification.js' type ProvidersModule = typeof import('../../utils/model/providers.js') // Helper to build a mock APIError with specific headers @@ -299,6 +300,45 @@ describe('abort retry classification', () => { }) describe('OpenAI-compatible retry classification', () => { + test('does not retry request timeouts marked as non-replayable', async () => { + process.env.OPENCLAUDE_RETRY_DELAY_MS = '1' + const { CannotRetryError, withRetry } = + await importFreshWithRetryModule('openai') + const error = markOpenAIRequestNonReplayable( + APIError.generate( + 0, + undefined, + 'OpenAI API transport error: no response headers [openai_category=request_timeout,host=slow.example.test]', + new Headers(), + ), + ) + let attempts = 0 + + let caught: unknown + try { + await drainAsyncGenerator( + withRetry( + async () => ({} as Anthropic), + async () => { + attempts++ + throw error + }, + { + maxRetries: 2, + model: 'gpt-4o-mini', + thinkingConfig: { type: 'disabled' }, + }, + ), + ) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(CannotRetryError) + expect((caught as { originalError?: unknown }).originalError).toBe(error) + expect(attempts).toBe(1) + }) + test('does not retry marked non-retryable auth failures', async () => { process.env.OPENCLAUDE_RETRY_DELAY_MS = '1' const { CannotRetryError, withRetry } = diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index 5299790dd8..3cd4e9a0f1 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -53,6 +53,7 @@ import { REPEATED_529_ERROR_MESSAGE, isOpenCodeGoQuotaError } from './errors.js' import { extractConnectionErrorDetails } from './errorUtils.js' import { extractOpenAICategoryMarker, + isOpenAIRequestNonReplayable, isRetryableOpenAICompatibilityFailureCategory, } from './openaiErrorClassification.js' @@ -856,6 +857,10 @@ function shouldRetry(error: APIError, persistentRetryEnabled: boolean): boolean return false } + if (isOpenAIRequestNonReplayable(error)) { + return false + } + // OpenCode Go subscription quota exhaustion is terminal — retrying burns // the same 429 and confuses the user with repeated "mysterious stop" // failures. getAssistantMessageFromError surfaces the actionable message. From 18adf7d20a814511174035d063f10d8857bea0e2 Mon Sep 17 00:00:00 2001 From: chioarub Date: Tue, 14 Jul 2026 18:52:37 +0300 Subject: [PATCH 06/17] fix(api): harden timeout cleanup and redaction --- .env.example | 2 + docs/advanced-setup.md | 2 +- src/services/api/openaiShim.test.ts | 61 +++++++++++++++- src/services/api/openaiShim.ts | 107 ++++++++++++++++++++++++---- 4 files changed, 158 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index a8eaa1fc5b..1b3d778825 100644 --- a/.env.example +++ b/.env.example @@ -422,6 +422,8 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # integer; invalid values use the default and values above 2147483647 are capped. # This runtime setting must be exported from your shell or launcher; the # provider env-file loader intentionally ignores runtime/debug knobs. +# Anthropic SDK and non-streaming fallback paths retain their existing +# provider-specific timeout handling. # API_TIMEOUT_MS=600000 # Enable debug logging diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 1c0c5c80e3..3cfdc89f36 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -417,7 +417,7 @@ already present as dev dependencies, so source/dev builds need no extra steps. | `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` | | `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` | | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | -| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for OpenAI-compatible requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher. | +| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for OpenAI-compatible requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher. Anthropic SDK and non-streaming fallback paths retain their existing provider-specific timeout handling. | | `OPENCLAUDE_OLLAMA_NUM_CTX` | Ollama only | Request-level Ollama context window. Defaults to `32768`; set a larger value for longer same-session history if your model and hardware can handle it. | | `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. | | `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` | No | JSON map of OpenAI-compatible model names to max output tokens, such as `{"custom-model":32768}`. Use this when a custom provider does not expose output-limit metadata from `/v1/models`. | diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index c046b5f2a4..da2b160bd0 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -1,5 +1,6 @@ import { APIError } from '@anthropic-ai/sdk' import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { getEventListeners } from 'node:events' import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' import { asMockFetch } from '../../test/typedMocks.js' import { _clearRegistryForTesting, ensureIntegrationsLoaded, registerGateway } from '../../integrations/index.ts' @@ -6802,6 +6803,26 @@ test('strips credentials and query params from URL in fetch network error messag expect(message).not.toContain('token=abc123') }) +test('redacts configured secret substrings from fetch network error messages', async () => { + const secret = 'route/key+AbC123' + process.env.OPENAI_API_KEY = secret + + globalThis.fetch = asMockFetch(mock(async () => { + throw new TypeError(`fetch failed while routing ${secret}`) + })) + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'test-model', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.not.toThrow(secret) +}) + test('classifies localhost transport failures with actionable category marker', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' @@ -6912,7 +6933,10 @@ test('classifies a pre-header API timeout without replaying the request', async process.env.OPENROUTER_API_KEY = escapeLookingSecret process.env.DEEPSEEK_API_KEY = malformedUtf8Secret process.env.OPENAI_BASE_URL = - `https://user:password@slow.example.test/v1/invalid%ZZ/${doubleEncodedPathSecret}/${encodedEscapeLookingSecret}/%E0%A4${encodedMalformedUtf8Secret}?token=secret` + `https://user:password@slow.example.test/v1/invalid%ZZ/${doubleEncodedPathSecret}/${encodedEscapeLookingSecret}/%E0%A4${encodedMalformedUtf8Secret}` + + `?prompt=${encodedPathSecret}&nested=${doubleEncodedPathSecret}` + + `&escape=${encodedEscapeLookingSecret}&malformed=%E0%A4${encodedMalformedUtf8Secret}` + + '&token=secret' let fetchCalls = 0 let completedGenerations = 0 const receivedBodies: string[] = [] @@ -7117,6 +7141,41 @@ test('manual signal fallback preserves caller cancellation after headers arrive' } }) +test('manual signal fallback removes caller forwarding after the body settles', async () => { + process.env.API_TIMEOUT_MS = '200' + globalThis.fetch = asMockFetch(mock(async () => + makeChatCompletionResponse('gpt-4o-mini'))) + + const caller = new AbortController() + const client = createOpenAIShimClient({}) as OpenAIShimClient + const originalAbortSignalAny = Object.getOwnPropertyDescriptor( + AbortSignal, + 'any', + ) + Object.defineProperty(AbortSignal, 'any', { + value: undefined, + configurable: true, + }) + try { + for (let request = 0; request < 2; request++) { + await client.beta.messages.create( + { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }, + { signal: caller.signal }, + ) + expect(getEventListeners(caller.signal, 'abort')).toHaveLength(0) + } + } finally { + if (originalAbortSignalAny) { + Object.defineProperty(AbortSignal, 'any', originalAbortSignalAny) + } + } +}) + test('disarms the API timeout after headers arrive while the body keeps streaming', async () => { process.env.API_TIMEOUT_MS = '20' const fetchSignals: AbortSignal[] = [] diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 1afb3bb255..c15dae2574 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -245,6 +245,7 @@ function combineRequestSignals( signal: AbortSignal cleanupAfterHeaders: () => void cleanup: () => void + cleanupAfterBody?: () => void } { if (!callerSignal) { return { @@ -256,6 +257,8 @@ function combineRequestSignals( if (typeof AbortSignal.any === 'function') { return { + // The deadline controller is request-local and its timer is the only + // abort source, so clearing that timer after headers permanently disarms it. signal: AbortSignal.any([callerSignal, deadlineSignal]), cleanupAfterHeaders: () => {}, cleanup: () => {}, @@ -287,7 +290,69 @@ function combineRequestSignals( abortFromDeadline() } - return { signal: combined.signal, cleanupAfterHeaders, cleanup } + return { + signal: combined.signal, + cleanupAfterHeaders, + cleanup, + cleanupAfterBody: cleanup, + } +} + +function wrapResponseBodyWithCleanup( + response: Response, + cleanup: () => void, +): Response { + if (!response.body) { + cleanup() + return response + } + + const reader = response.body.getReader() + let cleanedUp = false + const cleanupOnce = () => { + if (cleanedUp) return + cleanedUp = true + cleanup() + } + const body = new ReadableStream({ + async pull(controller) { + try { + const result = await reader.read() + if (result.done) { + cleanupOnce() + controller.close() + } else { + controller.enqueue(result.value) + } + } catch (error) { + cleanupOnce() + controller.error(error) + } + }, + async cancel(reason) { + try { + await reader.cancel(reason) + } finally { + cleanupOnce() + } + }, + }) + const wrapped = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + for (const property of ['url', 'type', 'redirected'] as const) { + try { + Object.defineProperty(wrapped, property, { + value: response[property], + configurable: true, + }) + } catch { + /* non-fatal: standard response metadata remains available */ + } + } + return wrapped } async function fetchWithHeadersDeadline( @@ -303,10 +368,12 @@ async function fetchWithHeadersDeadline( options.timeoutMs, redactUrlForDiagnostics(url), ) - const { signal, cleanupAfterHeaders, cleanup } = combineRequestSignals( - options.callerSignal, - deadlineController.signal, - ) + const { + signal, + cleanupAfterHeaders, + cleanup, + cleanupAfterBody, + } = combineRequestSignals(options.callerSignal, deadlineController.signal) const timer = setTimeout( () => deadlineController.abort(timeoutReason), options.timeoutMs, @@ -317,7 +384,9 @@ async function fetchWithHeadersDeadline( try { const response = await fetchWithProxyRetry(url, { ...init, signal }) headersReceived = true - return response + return cleanupAfterBody + ? wrapResponseBodyWithCleanup(response, cleanupAfterBody) + : response } catch (error) { if (options.callerSignal?.aborted) { throw preserveCallerAbortError(error, options.callerSignal) @@ -589,16 +658,18 @@ function decodeValidUrlEscapesOnce(value: string): string { return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, decodeValidPercentRun) } -function redactDecodedPathSecrets(value: string): string { +function redactDecodedUrlComponentSecrets(value: string): string { let decoded = value + let foundSecret = false while (true) { const redacted = redactSecretSubstringsForDisplay( decoded, process.env as SecretValueSource, ) ?? decoded + if (redacted !== decoded) foundSecret = true const next = decodeValidUrlEscapesOnce(redacted) - if (next === redacted) return redacted + if (next === redacted) return foundSecret ? redacted : value decoded = next } } @@ -607,13 +678,20 @@ function redactUrlForDiagnostics(url: string): string { let redacted = redactUrlForDisplay(url) try { const parsed = new URL(redacted) - const redactedPathname = redactDecodedPathSecrets(parsed.pathname) + const redactedPathname = redactDecodedUrlComponentSecrets(parsed.pathname) + const redactedSearch = redactDecodedUrlComponentSecrets(parsed.search) + let componentRedacted = false if (redactedPathname !== parsed.pathname) { parsed.pathname = redactedPathname - redacted = parsed.toString() + componentRedacted = true + } + if (redactedSearch !== parsed.search) { + parsed.search = redactedSearch + componentRedacted = true } + if (componentRedacted) redacted = parsed.toString() } catch { - // Keep the URL-level redaction when the pathname is not decodable. + // Keep the URL-level redaction when the URL cannot be parsed. } const redactedSubstrings = redactSecretSubstringsForDisplay( @@ -644,9 +722,14 @@ function createClassifiedTransportError( url: requestUrl, }) const redactedUrl = redactUrlForDiagnostics(requestUrl) + const redactedMessage = + redactSecretSubstringsForDisplay( + redactUrlsInMessage(failure.message), + process.env as SecretValueSource, + ) ?? 'Request failed' const safeMessage = redactSecretValueForDisplay( - redactUrlsInMessage(failure.message), + redactedMessage, process.env as SecretValueSource, ) || 'Request failed' From 03546d21cb8dccbd6852d6888b00d33a6888b910 Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 15 Jul 2026 02:38:59 +0300 Subject: [PATCH 07/17] fix(api): redact encoded transport credentials --- src/services/api/openaiShim.test.ts | 51 ++++++++++++++++++++++++- src/services/api/openaiShim.ts | 12 +++++- src/utils/providerSecrets.ts | 58 +++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index da2b160bd0..a8119cd154 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -10,7 +10,10 @@ import { getAssistantMessageFromError, OPENCODE_GO_FREE_LIMIT_ERROR_MESSAGE, } from './errors.ts' -import { isOpenAIRequestNonReplayable } from './openaiErrorClassification.ts' +import { + extractOpenAICategoryMarker, + isOpenAIRequestNonReplayable, +} from './openaiErrorClassification.ts' import { createOpenAIShimClient, hasMistralApiHost } from './openaiShim.ts' import * as realCodexShim from './codexShim.js' import * as realGithubModelsCredentials from '../../utils/githubModelsCredentials.js' @@ -6823,6 +6826,52 @@ test('redacts configured secret substrings from fetch network error messages', a ).rejects.not.toThrow(secret) }) +test('redacts encoded configured secrets from non-URL transport error messages', async () => { + const secret = 'route/key+AbC123' + const encodedSecret = encodeURIComponent(secret) + const doubleEncodedSecret = encodeURIComponent(encodedSecret) + const fullyEncodedSecret = Array.from(new TextEncoder().encode(secret)) + .map(byte => `%${byte.toString(16).padStart(2, '0')}`) + .join('') + const malformedAdjacentSecret = `%E0%A4${encodedSecret}` + const encodedCategoryMarker = '%5Bopenai_category%3Dauth_invalid%5D' + const encodedControlSequence = '%1B%5B31m' + process.env.OPENAI_API_KEY = secret + + globalThis.fetch = asMockFetch(mock(async () => { + throw new TypeError( + `proxy failed ${encodedCategoryMarker} ${encodedControlSequence} for path /v1/${encodedSecret}?nested=${doubleEncodedSecret}&fully=${fullyEncodedSecret}&malformed=${malformedAdjacentSecret}`, + ) + })) + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + let caught: unknown + try { + await client.beta.messages.create({ + model: 'test-model', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + } catch (error) { + caught = error + } + + expect(caught).toBeDefined() + const message = (caught as Error).message + expect(message).toContain('proxy failed') + expect(message).toContain(encodedCategoryMarker) + expect(message).toContain(encodedControlSequence) + expect(message).not.toContain('\u001B') + expect(extractOpenAICategoryMarker(message)).toBe('network_error') + expect(message).not.toContain(secret) + expect(message).not.toContain(encodedSecret) + expect(message).not.toContain(doubleEncodedSecret) + expect(message).not.toContain(fullyEncodedSecret) + expect(message).not.toContain(malformedAdjacentSecret) +}) + test('classifies localhost transport failures with actionable category marker', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index c15dae2574..9757016509 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -98,7 +98,10 @@ import { } from './openaiErrorClassification.js' import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js' import { redactSecretValueForDisplay, type SecretValueSource } from '../../utils/providerProfile.js' -import { redactSecretSubstringsForDisplay } from '../../utils/providerSecrets.js' +import { + redactEncodedSecretSubstringsForDisplay, + redactSecretSubstringsForDisplay, +} from '../../utils/providerSecrets.js' import { redactUrlForDisplay, shouldRedactUrlQueryParam, @@ -722,9 +725,14 @@ function createClassifiedTransportError( url: requestUrl, }) const redactedUrl = redactUrlForDiagnostics(requestUrl) + const encodedSecretRedactedMessage = + redactEncodedSecretSubstringsForDisplay( + redactUrlsInMessage(failure.message), + process.env as SecretValueSource, + ) ?? 'Request failed' const redactedMessage = redactSecretSubstringsForDisplay( - redactUrlsInMessage(failure.message), + encodedSecretRedactedMessage, process.env as SecretValueSource, ) ?? 'Request failed' const safeMessage = diff --git a/src/utils/providerSecrets.ts b/src/utils/providerSecrets.ts index 586673cfbb..8dc2476202 100644 --- a/src/utils/providerSecrets.ts +++ b/src/utils/providerSecrets.ts @@ -266,6 +266,64 @@ export function redactSecretSubstringsForDisplay( return redacted } +const MAX_SECRET_ENCODING_LAYERS = 4 + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function encodedVariantPattern(value: string): string { + return Array.from(value, char => { + const lower = char.toLowerCase() + return lower >= 'a' && lower <= 'f' + ? `[${lower}${lower.toUpperCase()}]` + : escapeRegex(char) + }).join('') +} + +function percentEncodeUtf8(value: string): string { + return Array.from( + new TextEncoder().encode(value), + byte => `%${byte.toString(16).padStart(2, '0').toUpperCase()}`, + ).join('') +} + +function encodedSecretPattern(value: string): RegExp { + const characterPatterns = Array.from(value, char => { + const variants = [escapeRegex(char)] + let encoded = percentEncodeUtf8(char) + for (let layer = 0; layer < MAX_SECRET_ENCODING_LAYERS; layer++) { + variants.push(encodedVariantPattern(encoded)) + encoded = encodeURIComponent(encoded) + } + return `(?:${variants.join('|')})` + }) + return new RegExp(characterPatterns.join(''), 'g') +} + +/** + * Redacts configured secrets when each character is literal or percent-encoded + * without decoding unrelated message text. Encoding depth is explicitly + * bounded to keep matching predictable on untrusted diagnostics. + */ +export function redactEncodedSecretSubstringsForDisplay( + value: string | null | undefined, + ...sources: Array +): string | undefined { + if (!value) return undefined + + let redacted = value + const secretValues = collectSecretValues(sources).sort( + (a, b) => b.length - a.length, + ) + for (const secretValue of secretValues) { + const mask = maskSecretForDisplay(secretValue) ?? 'configured' + redacted = redacted.replace(encodedSecretPattern(secretValue), mask) + } + + return redacted +} + export function sanitizeProviderConfigValue( value: string | null | undefined, ...sources: Array From 60166e2b0e8034836a3900dbee357100936a63ac Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 15 Jul 2026 08:04:46 +0300 Subject: [PATCH 08/17] fix(api): harden deadline retries and URL redaction --- src/services/api/fetchWithProxyRetry.test.ts | 21 +++ src/services/api/fetchWithProxyRetry.ts | 12 +- src/services/api/openaiShim.test.ts | 135 +++++++++++++++++++ src/services/api/openaiShim.ts | 49 ++++--- 4 files changed, 196 insertions(+), 21 deletions(-) diff --git a/src/services/api/fetchWithProxyRetry.test.ts b/src/services/api/fetchWithProxyRetry.test.ts index 970bbf4431..8bff1b1797 100644 --- a/src/services/api/fetchWithProxyRetry.test.ts +++ b/src/services/api/fetchWithProxyRetry.test.ts @@ -143,3 +143,24 @@ test('fetchWithProxyRetry retries and disables keepalive after receiving a 504 r expect((calls[0] as RequestInit).keepalive).toBeUndefined() expect((calls[1] as RequestInit).keepalive).toBe(false) }) + +test('fetchWithProxyRetry does not retry a 504 after the request is aborted', async () => { + const controller = new AbortController() + const abortReason = new DOMException('Deadline exceeded', 'TimeoutError') + let attempts = 0 + + globalThis.fetch = (async () => { + attempts++ + controller.abort(abortReason) + return new Response('Gateway Timeout', { status: 504 }) + }) as unknown as FetchType + + await expect( + fetchWithProxyRetry('https://example.com/generate', { + method: 'POST', + signal: controller.signal, + }), + ).rejects.toBe(abortReason) + + expect(attempts).toBe(1) +}) diff --git a/src/services/api/fetchWithProxyRetry.ts b/src/services/api/fetchWithProxyRetry.ts index 1c02965027..df984770af 100644 --- a/src/services/api/fetchWithProxyRetry.ts +++ b/src/services/api/fetchWithProxyRetry.ts @@ -29,6 +29,12 @@ export async function fetchWithProxyRetry( forAnthropicAPI: options?.forAnthropicAPI, }), }) + if (init?.signal?.aborted) { + throw ( + init.signal.reason ?? + new DOMException('The operation was aborted.', 'AbortError') + ) + } // If an upstream proxy or local NAT silently dropped the keep-alive socket, // it might result in a 502/504 response instead of a hard network exception. @@ -44,7 +50,11 @@ export async function fetchWithProxyRetry( return response } catch (error) { lastError = error - if (attempt >= maxAttempts || !isRetryableFetchError(error)) { + if ( + init?.signal?.aborted || + attempt >= maxAttempts || + !isRetryableFetchError(error) + ) { throw error } disableKeepAlive() diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index a8119cd154..2800113614 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -7055,6 +7055,141 @@ test('classifies a pre-header API timeout without replaying the request', async expect(completedGenerations).toBe(1) }) +test('does not proxy-retry when a deadline abort surfaces as fetch failed', async () => { + process.env.API_TIMEOUT_MS = '20' + let fetchCalls = 0 + globalThis.fetch = (async (_input, init) => { + fetchCalls++ + return new Promise((_resolve, reject) => { + const signal = init?.signal + const rejectFromAbort = () => reject(new TypeError('fetch failed')) + signal?.addEventListener('abort', rejectFromAbort, { once: true }) + if (signal?.aborted) rejectFromAbort() + }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.toThrow('no response headers within 20ms (API_TIMEOUT_MS)') + + expect(fetchCalls).toBe(1) +}) + +test('deadline wins when an abort-ignoring fetch resolves 504 afterward', async () => { + process.env.API_TIMEOUT_MS = '20' + let fetchCalls = 0 + globalThis.fetch = (async (_input, init) => { + fetchCalls++ + return new Promise(resolve => { + const resolveAfterAbort = () => { + resolve(new Response('Gateway Timeout', { status: 504 })) + } + init?.signal?.addEventListener('abort', resolveAfterAbort, { once: true }) + if (init?.signal?.aborted) resolveAfterAbort() + }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await expect( + client.beta.messages.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }), + ).rejects.toThrow('no response headers within 20ms (API_TIMEOUT_MS)') + + expect(fetchCalls).toBe(1) +}) + +test('bounds nested URL decoding while retaining encoded secret redaction', async () => { + const secret = 'route/key+AbC123' + let encodedSecret = secret + for (let layer = 0; layer < 4; layer++) { + encodedSecret = encodeURIComponent(encodedSecret) + } + const deeplyNestedValue = `%${'25'.repeat(200)}` + process.env.OPENAI_API_KEY = secret + process.env.OPENAI_BASE_URL = + `https://slow.example.test/v1/${deeplyNestedValue}/${encodedSecret}` + globalThis.fetch = asMockFetch(mock(async () => { + throw new TypeError('fetch failed') + })) + + const originalDecodeURIComponent = globalThis.decodeURIComponent + let decodeCalls = 0 + globalThis.decodeURIComponent = ((value: string) => { + decodeCalls++ + return originalDecodeURIComponent(value) + }) as typeof decodeURIComponent + + const client = createOpenAIShimClient({}) as OpenAIShimClient + let caught: unknown + try { + await client.beta.messages.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + } catch (error) { + caught = error + } finally { + globalThis.decodeURIComponent = originalDecodeURIComponent + } + + expect(caught).toBeDefined() + expect((caught as Error).message).not.toContain(secret) + expect((caught as Error).message).not.toContain(encodedSecret) + expect(decodeCalls).toBeLessThanOrEqual(32) +}) + +test('decodes malformed URL escape runs in linear work', async () => { + const secret = 'route/key+AbC123' + const malformedEscapeCount = 200 + const malformedUtf8Run = '%80'.repeat(malformedEscapeCount) + process.env.OPENAI_API_KEY = secret + process.env.OPENAI_BASE_URL = + `https://slow.example.test/v1/${malformedUtf8Run}/${encodeURIComponent(secret)}` + globalThis.fetch = asMockFetch(mock(async () => { + throw new TypeError('fetch failed') + })) + + const originalDecodeURIComponent = globalThis.decodeURIComponent + let malformedDecodeCalls = 0 + globalThis.decodeURIComponent = ((value: string) => { + if (value.includes('%80')) malformedDecodeCalls++ + return originalDecodeURIComponent(value) + }) as typeof decodeURIComponent + + const client = createOpenAIShimClient({}) as OpenAIShimClient + let caught: unknown + try { + await client.beta.messages.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + } catch (error) { + caught = error + } finally { + globalThis.decodeURIComponent = originalDecodeURIComponent + } + + expect(caught).toBeDefined() + expect((caught as Error).message).not.toContain(secret) + expect(malformedDecodeCalls).toBeLessThanOrEqual(malformedEscapeCount * 10) +}) + test('preserves caller cancellation while waiting for response headers without retrying', async () => { process.env.API_TIMEOUT_MS = '200' let fetchCalls = 0 diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 9757016509..36cfa2b758 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -632,27 +632,26 @@ function decodeValidPercentRun(encoded: string): string { let decoded = '' let offset = 0 while (offset < escapes.length) { - let decodedPrefix: string | undefined - let prefixLength = escapes.length - offset - while (prefixLength > 0) { - try { - decodedPrefix = decodeURIComponent( - escapes.slice(offset, offset + prefixLength).join(''), - ) - break - } catch { - prefixLength-- - } - } - - if (decodedPrefix === undefined) { + const firstByte = Number.parseInt(escapes[offset].slice(1), 16) + const sequenceLength = + firstByte <= 0x7f + ? 1 + : firstByte >= 0xc2 && firstByte <= 0xdf + ? 2 + : firstByte >= 0xe0 && firstByte <= 0xef + ? 3 + : firstByte >= 0xf0 && firstByte <= 0xf4 + ? 4 + : 1 + try { + decoded += decodeURIComponent( + escapes.slice(offset, offset + sequenceLength).join(''), + ) + offset += sequenceLength + } catch { decoded += escapes[offset] offset++ - continue } - - decoded += decodedPrefix - offset += prefixLength } return decoded } @@ -661,20 +660,30 @@ function decodeValidUrlEscapesOnce(value: string): string { return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, decodeValidPercentRun) } +const MAX_URL_SECRET_DECODING_LAYERS = 4 + function redactDecodedUrlComponentSecrets(value: string): string { let decoded = value let foundSecret = false - while (true) { + for (let layer = 0; layer <= MAX_URL_SECRET_DECODING_LAYERS; layer++) { const redacted = redactSecretSubstringsForDisplay( decoded, process.env as SecretValueSource, ) ?? decoded if (redacted !== decoded) foundSecret = true + if (layer === MAX_URL_SECRET_DECODING_LAYERS) { + decoded = redacted + break + } const next = decodeValidUrlEscapesOnce(redacted) - if (next === redacted) return foundSecret ? redacted : value + if (next === redacted) { + decoded = redacted + break + } decoded = next } + return foundSecret ? decoded : value } function redactUrlForDiagnostics(url: string): string { From 97b71d54c2c05241923fae0ad04828855dffdb9b Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 15 Jul 2026 08:20:56 +0300 Subject: [PATCH 09/17] fix(api): preserve aborted fetch reasons --- src/services/api/fetchWithProxyRetry.test.ts | 48 ++++++++++++++++++++ src/services/api/fetchWithProxyRetry.ts | 6 ++- src/services/api/openaiShim.test.ts | 17 ++++--- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/services/api/fetchWithProxyRetry.test.ts b/src/services/api/fetchWithProxyRetry.test.ts index 8bff1b1797..a8c6dcd52e 100644 --- a/src/services/api/fetchWithProxyRetry.test.ts +++ b/src/services/api/fetchWithProxyRetry.test.ts @@ -164,3 +164,51 @@ test('fetchWithProxyRetry does not retry a 504 after the request is aborted', as expect(attempts).toBe(1) }) + +test('fetchWithProxyRetry preserves the abort reason for a generic fetch failure', async () => { + for (const message of ['fetch failed', 'invalid_argument']) { + const controller = new AbortController() + const abortReason = new DOMException('Deadline exceeded', 'TimeoutError') + let attempts = 0 + + globalThis.fetch = (async () => { + attempts++ + controller.abort(abortReason) + throw new TypeError(message) + }) as unknown as FetchType + + await expect( + fetchWithProxyRetry('https://example.com/generate', { + method: 'POST', + signal: controller.signal, + }), + ).rejects.toBe(abortReason) + + expect(attempts).toBe(1) + } +}) + +test('fetchWithProxyRetry preserves an explicit AbortError from fetch', async () => { + const controller = new AbortController() + const abortReason = new DOMException('Caller cancelled', 'AbortError') + const fetchAbortError = new DOMException( + 'The operation was aborted.', + 'AbortError', + ) + let attempts = 0 + + globalThis.fetch = (async () => { + attempts++ + controller.abort(abortReason) + throw fetchAbortError + }) as unknown as FetchType + + await expect( + fetchWithProxyRetry('https://example.com/generate', { + method: 'POST', + signal: controller.signal, + }), + ).rejects.toBe(fetchAbortError) + + expect(attempts).toBe(1) +}) diff --git a/src/services/api/fetchWithProxyRetry.ts b/src/services/api/fetchWithProxyRetry.ts index df984770af..1f12dff34e 100644 --- a/src/services/api/fetchWithProxyRetry.ts +++ b/src/services/api/fetchWithProxyRetry.ts @@ -50,8 +50,12 @@ export async function fetchWithProxyRetry( return response } catch (error) { lastError = error + if (init?.signal?.aborted) { + throw error instanceof Error && error.name === 'AbortError' + ? error + : (init.signal.reason ?? error) + } if ( - init?.signal?.aborted || attempt >= maxAttempts || !isRetryableFetchError(error) ) { diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 2800113614..dbdae37fd7 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -7112,14 +7112,16 @@ test('deadline wins when an abort-ignoring fetch resolves 504 afterward', async test('bounds nested URL decoding while retaining encoded secret redaction', async () => { const secret = 'route/key+AbC123' - let encodedSecret = secret + const secretVariants = [secret] for (let layer = 0; layer < 4; layer++) { - encodedSecret = encodeURIComponent(encodedSecret) + secretVariants.push( + encodeURIComponent(secretVariants[secretVariants.length - 1]!), + ) } const deeplyNestedValue = `%${'25'.repeat(200)}` process.env.OPENAI_API_KEY = secret process.env.OPENAI_BASE_URL = - `https://slow.example.test/v1/${deeplyNestedValue}/${encodedSecret}` + `https://slow.example.test/v1/${deeplyNestedValue}/${secretVariants[4]}` globalThis.fetch = asMockFetch(mock(async () => { throw new TypeError('fetch failed') })) @@ -7147,18 +7149,20 @@ test('bounds nested URL decoding while retaining encoded secret redaction', asyn } expect(caught).toBeDefined() - expect((caught as Error).message).not.toContain(secret) - expect((caught as Error).message).not.toContain(encodedSecret) + for (const secretVariant of secretVariants) { + expect((caught as Error).message).not.toContain(secretVariant) + } expect(decodeCalls).toBeLessThanOrEqual(32) }) test('decodes malformed URL escape runs in linear work', async () => { const secret = 'route/key+AbC123' + const encodedSecret = encodeURIComponent(secret) const malformedEscapeCount = 200 const malformedUtf8Run = '%80'.repeat(malformedEscapeCount) process.env.OPENAI_API_KEY = secret process.env.OPENAI_BASE_URL = - `https://slow.example.test/v1/${malformedUtf8Run}/${encodeURIComponent(secret)}` + `https://slow.example.test/v1/${malformedUtf8Run}/${encodedSecret}` globalThis.fetch = asMockFetch(mock(async () => { throw new TypeError('fetch failed') })) @@ -7187,6 +7191,7 @@ test('decodes malformed URL escape runs in linear work', async () => { expect(caught).toBeDefined() expect((caught as Error).message).not.toContain(secret) + expect((caught as Error).message).not.toContain(encodedSecret) expect(malformedDecodeCalls).toBeLessThanOrEqual(malformedEscapeCount * 10) }) From 04316354cf776f1b42c79f349fe4c6f2cd44f7b5 Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 15 Jul 2026 17:56:05 +0300 Subject: [PATCH 10/17] fix(api): preserve caller abort reasons --- src/services/api/openaiShim.test.ts | 47 +++++++++++++++++++++++++++-- src/services/api/openaiShim.ts | 2 +- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index dbdae37fd7..51e648018a 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -6942,7 +6942,7 @@ test('transport failures are not labeled with HTTP status 503', async () => { expect(err.message).toContain('openai_category=network_error') }) -test('propagates AbortError without wrapping it as transport failure', async () => { +test('propagates caller AbortError without wrapping it as transport failure', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' const abortError = new DOMException('The operation was aborted.', 'AbortError') @@ -6951,7 +6951,8 @@ test('propagates AbortError without wrapping it as transport failure', async () })) const controller = new AbortController() - controller.abort() + const callerReason = new DOMException('Cancelled by caller', 'AbortError') + controller.abort(callerReason) const client = createOpenAIShimClient({}) as OpenAIShimClient @@ -6965,7 +6966,7 @@ test('propagates AbortError without wrapping it as transport failure', async () }, { signal: controller.signal }, ), - ).rejects.toBe(abortError) + ).rejects.toBe(callerReason) }) test('classifies a pre-header API timeout without replaying the request', async () => { @@ -7238,6 +7239,46 @@ test('preserves caller cancellation while waiting for response headers without r } }) +test('native signal composition preserves the caller abort reason when fetch rejects AbortError', async () => { + expect(typeof AbortSignal.any).toBe('function') + process.env.API_TIMEOUT_MS = '200' + let fetchCalls = 0 + const fetchAbortError = new DOMException( + 'The operation was aborted.', + 'AbortError', + ) + globalThis.fetch = (async (_input, init) => { + fetchCalls++ + return new Promise((_resolve, reject) => { + const signal = init?.signal + if (!signal) return + const rejectFromAbort = () => reject(fetchAbortError) + signal.addEventListener('abort', rejectFromAbort, { once: true }) + if (signal.aborted) rejectFromAbort() + }) + }) as unknown as FetchType + + const caller = new AbortController() + const callerReason = new DOMException('Cancelled by user', 'AbortError') + const client = createOpenAIShimClient({}) as OpenAIShimClient + const request = client.beta.messages.create( + { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }, + { signal: caller.signal }, + ) + + setTimeout(() => caller.abort(callerReason), 10) + + await expect( + waitForPromise(request, 500, 'caller abort did not settle'), + ).rejects.toBe(callerReason) + expect(fetchCalls).toBe(1) +}) + test('caller abort winning the timeout catch race prevents a retry', async () => { process.env.API_TIMEOUT_MS = '20' let fetchCalls = 0 diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 36cfa2b758..3cccdc55c5 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -166,7 +166,7 @@ function preserveCallerAbortError( error: unknown, callerSignal: AbortSignal, ): unknown { - return error instanceof ResponseHeadersTimeoutError + return error instanceof ResponseHeadersTimeoutError || isAbortError(error) ? callerSignal.reason ?? error : error } From 34f056cd49d3792fbe0279e9ab1f90cca2543b9b Mon Sep 17 00:00:00 2001 From: chioarub Date: Wed, 15 Jul 2026 18:05:16 +0300 Subject: [PATCH 11/17] test(api): clear caller abort timer --- src/services/api/openaiShim.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 51e648018a..86a6359d28 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -7271,11 +7271,15 @@ test('native signal composition preserves the caller abort reason when fetch rej { signal: caller.signal }, ) - setTimeout(() => caller.abort(callerReason), 10) + const abortTimer = setTimeout(() => caller.abort(callerReason), 10) - await expect( - waitForPromise(request, 500, 'caller abort did not settle'), - ).rejects.toBe(callerReason) + try { + await expect( + waitForPromise(request, 500, 'caller abort did not settle'), + ).rejects.toBe(callerReason) + } finally { + clearTimeout(abortTimer) + } expect(fetchCalls).toBe(1) }) From 1eccccc6528a9d90618fbdb34ad1270997b8b96c Mon Sep 17 00:00:00 2001 From: chioarub Date: Thu, 16 Jul 2026 08:18:01 +0300 Subject: [PATCH 12/17] docs(api): clarify API_TIMEOUT_MS transport scope --- .env.example | 5 +++-- docs/advanced-setup.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index d9991033f8..f59c075bd9 100644 --- a/.env.example +++ b/.env.example @@ -428,8 +428,9 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here # integer; invalid values use the default and values above 2147483647 are capped. # This runtime setting must be exported from your shell or launcher; the # provider env-file loader intentionally ignores runtime/debug knobs. -# Anthropic SDK and non-streaming fallback paths retain their existing -# provider-specific timeout handling. +# This covers generic OpenAI-compatible requests, direct GitHub Copilot +# Responses, and Copilot chat-to-Responses fallback requests. First-party +# Codex OAuth Responses and the Anthropic SDK retain their existing handling. # API_TIMEOUT_MS=600000 # Enable debug logging diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 8b4bdc5902..8953e1a7db 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -437,7 +437,7 @@ token; do not set both credentials. | `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` | | `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` | | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | -| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for OpenAI-compatible requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher. Anthropic SDK and non-streaming fallback paths retain their existing provider-specific timeout handling. | +| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for generic OpenAI-compatible requests, direct GitHub Copilot Responses, and Copilot chat-to-Responses fallback requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher. First-party Codex OAuth Responses and the Anthropic SDK retain their existing timeout handling. | | `OPENCLAUDE_OLLAMA_NUM_CTX` | Ollama only | Request-level Ollama context window. Defaults to `32768`; set a larger value for longer same-session history if your model and hardware can handle it. | | `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. | | `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` | No | JSON map of OpenAI-compatible model names to max output tokens, such as `{"custom-model":32768}`. Use this when a custom provider does not expose output-limit metadata from `/v1/models`. | From fdcec3e5631549df49177850d108bf80807607ce Mon Sep 17 00:00:00 2001 From: chioarub Date: Thu, 16 Jul 2026 08:24:48 +0300 Subject: [PATCH 13/17] docs(api): explain timeout env loading --- docs/advanced-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 8953e1a7db..db306a79ab 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -437,7 +437,7 @@ token; do not set both credentials. | `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` | | `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` | | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | -| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for generic OpenAI-compatible requests, direct GitHub Copilot Responses, and Copilot chat-to-Responses fallback requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher. First-party Codex OAuth Responses and the Anthropic SDK retain their existing timeout handling. | +| `API_TIMEOUT_MS` | No | Time-to-response-headers deadline for generic OpenAI-compatible requests, direct GitHub Copilot Responses, and Copilot chat-to-Responses fallback requests, in milliseconds (default: `600000`, or 10 minutes). The value must be a safe positive integer; invalid, zero, negative, or fractional values use the default, and values above `2147483647` are capped. The deadline is disarmed after headers arrive, so it does not limit response streaming. Export this runtime setting from your shell or launcher; the provider env-file loader ignores runtime/debug settings, so a value configured only there leaves the default in effect. First-party Codex OAuth Responses and the Anthropic SDK retain their existing timeout handling. | | `OPENCLAUDE_OLLAMA_NUM_CTX` | Ollama only | Request-level Ollama context window. Defaults to `32768`; set a larger value for longer same-session history if your model and hardware can handle it. | | `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. | | `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` | No | JSON map of OpenAI-compatible model names to max output tokens, such as `{"custom-model":32768}`. Use this when a custom provider does not expose output-limit metadata from `/v1/models`. | From 068b2c909570f38ac4aa6033350d627217aeda5b Mon Sep 17 00:00:00 2001 From: jatmn Date: Fri, 17 Jul 2026 16:51:15 -0700 Subject: [PATCH 14/17] fix(api): reset deadline for proxy retries --- src/services/api/fetchWithProxyRetry.ts | 10 ++- src/services/api/openaiShim.test.ts | 25 +++++++ src/services/api/openaiShim.ts | 95 ++++++++++++++----------- 3 files changed, 88 insertions(+), 42 deletions(-) diff --git a/src/services/api/fetchWithProxyRetry.ts b/src/services/api/fetchWithProxyRetry.ts index 1f12dff34e..7bda7135a8 100644 --- a/src/services/api/fetchWithProxyRetry.ts +++ b/src/services/api/fetchWithProxyRetry.ts @@ -16,14 +16,19 @@ export function isRetryableFetchError(error: unknown): boolean { export async function fetchWithProxyRetry( input: string | URL | Request, init?: RequestInit, - options?: { forAnthropicAPI?: boolean; maxAttempts?: number }, + options?: { + forAnthropicAPI?: boolean + maxAttempts?: number + fetcher?: typeof fetch + }, ): Promise { const maxAttempts = Math.max(1, options?.maxAttempts ?? 2) + const fetcher = options?.fetcher ?? fetch let lastError: unknown for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - const response = await fetch(input, { + const response = await fetcher(input, { ...init, ...getProxyFetchOptions({ forAnthropicAPI: options?.forAnthropicAPI, @@ -43,6 +48,7 @@ export async function fetchWithProxyRetry( (response.status === 502 || response.status === 504) && attempt < maxAttempts ) { + await response.body?.cancel().catch(() => {}) disableKeepAlive() continue } diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index fbc4a931c7..bfc8e06973 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -7142,6 +7142,31 @@ test('deadline wins when an abort-ignoring fetch resolves 504 afterward', async expect(fetchCalls).toBe(1) }) +test('gives a proxy retry its own response-header deadline', async () => { + process.env.API_TIMEOUT_MS = '50' + let fetchCalls = 0 + globalThis.fetch = (async () => { + fetchCalls++ + if (fetchCalls === 1) { + await new Promise(resolve => setTimeout(resolve, 30)) + return new Response('Gateway Timeout', { status: 504 }) + } + await new Promise(resolve => setTimeout(resolve, 30)) + return makeChatCompletionResponse('gpt-4o-mini') + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const response = await client.beta.messages.create({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + expect(response).toBeDefined() + expect(fetchCalls).toBe(2) +}) + test('bounds nested URL decoding while retaining encoded secret redaction', async () => { const secret = 'route/key+AbC123' const secretVariants = [secret] diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index b7dedbf0f4..a867ba3bb8 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -366,49 +366,64 @@ async function fetchWithHeadersDeadline( timeoutMs: number }, ): Promise { - const deadlineController = new AbortController() - const timeoutReason = new ResponseHeadersTimeoutError( - options.timeoutMs, - redactUrlForDiagnostics(url), - ) - const { - signal, - cleanupAfterHeaders, - cleanup, - cleanupAfterBody, - } = combineRequestSignals(options.callerSignal, deadlineController.signal) - const timer = setTimeout( - () => deadlineController.abort(timeoutReason), - options.timeoutMs, - ) - timer.unref?.() + const redactedUrl = redactUrlForDiagnostics(url) + const fetchWithAttemptDeadline: typeof fetch = async (input, attemptInit) => { + const deadlineController = new AbortController() + const timeoutReason = new ResponseHeadersTimeoutError( + options.timeoutMs, + redactedUrl, + ) + const { + signal, + cleanupAfterHeaders, + cleanup, + cleanupAfterBody, + } = combineRequestSignals(options.callerSignal, deadlineController.signal) + const timer = setTimeout( + () => deadlineController.abort(timeoutReason), + options.timeoutMs, + ) + timer.unref?.() - let headersReceived = false - try { - const response = await fetchWithProxyRetry(url, { ...init, signal }) - headersReceived = true - return cleanupAfterBody - ? wrapResponseBodyWithCleanup(response, cleanupAfterBody) - : response - } catch (error) { - if (options.callerSignal?.aborted) { - throw preserveCallerAbortError(error, options.callerSignal) - } - if ( - deadlineController.signal.aborted && - deadlineController.signal.reason === timeoutReason - ) { - throw timeoutReason - } - throw error - } finally { - clearTimeout(timer) - if (headersReceived) { - cleanupAfterHeaders() - } else { - cleanup() + let headersReceived = false + try { + const response = await fetch(input, { ...attemptInit, signal }) + if (signal.aborted) { + throw ( + signal.reason ?? + new DOMException('The operation was aborted.', 'AbortError') + ) + } + headersReceived = true + return cleanupAfterBody + ? wrapResponseBodyWithCleanup(response, cleanupAfterBody) + : response + } catch (error) { + if (options.callerSignal?.aborted) { + throw preserveCallerAbortError(error, options.callerSignal) + } + if ( + deadlineController.signal.aborted && + deadlineController.signal.reason === timeoutReason + ) { + throw timeoutReason + } + throw error + } finally { + clearTimeout(timer) + if (headersReceived) { + cleanupAfterHeaders() + } else { + cleanup() + } } } + + return fetchWithProxyRetry( + url, + { ...init, signal: options.callerSignal }, + { fetcher: fetchWithAttemptDeadline }, + ) } async function readWithIdleTimeout( From 04d8f2e9a363fbee20506cbc88a7231c0927bb03 Mon Sep 17 00:00:00 2001 From: jatmn Date: Fri, 17 Jul 2026 17:21:34 -0700 Subject: [PATCH 15/17] fix(api): type deadline fetch adapter --- src/services/api/fetchWithProxyRetry.ts | 7 ++++++- src/services/api/openaiShim.ts | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/services/api/fetchWithProxyRetry.ts b/src/services/api/fetchWithProxyRetry.ts index 7bda7135a8..aa2484df55 100644 --- a/src/services/api/fetchWithProxyRetry.ts +++ b/src/services/api/fetchWithProxyRetry.ts @@ -3,6 +3,11 @@ import { disableKeepAlive, getProxyFetchOptions } from '../../utils/proxy.js' const RETRYABLE_FETCH_ERROR_PATTERN = /socket connection was closed unexpectedly|ECONNRESET|EPIPE|socket hang up|Connection reset by peer|fetch failed/i +export type ProxyRetryFetcher = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise + export function isRetryableFetchError(error: unknown): boolean { if (!(error instanceof Error)) { return false @@ -19,7 +24,7 @@ export async function fetchWithProxyRetry( options?: { forAnthropicAPI?: boolean maxAttempts?: number - fetcher?: typeof fetch + fetcher?: ProxyRetryFetcher }, ): Promise { const maxAttempts = Math.max(1, options?.maxAttempts ?? 2) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index a867ba3bb8..523991444d 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -77,7 +77,10 @@ import { } from './codexShim.js' import { buildAnthropicUsageFromRawUsage } from './cacheMetrics.js' import { compressToolHistory } from './compressToolHistory.js' -import { fetchWithProxyRetry } from './fetchWithProxyRetry.js' +import { + fetchWithProxyRetry, + type ProxyRetryFetcher, +} from './fetchWithProxyRetry.js' import { getLocalFastPathConfig, getLocalProviderRetryBaseUrls, @@ -367,7 +370,7 @@ async function fetchWithHeadersDeadline( }, ): Promise { const redactedUrl = redactUrlForDiagnostics(url) - const fetchWithAttemptDeadline: typeof fetch = async (input, attemptInit) => { + const fetchWithAttemptDeadline: ProxyRetryFetcher = async (input, attemptInit) => { const deadlineController = new AbortController() const timeoutReason = new ResponseHeadersTimeoutError( options.timeoutMs, From f791528ddd9b49abf63398606654cededd96cd55 Mon Sep 17 00:00:00 2001 From: jatmn Date: Fri, 17 Jul 2026 17:30:53 -0700 Subject: [PATCH 16/17] fix(api): honor abort cleanup and request signals --- src/services/api/fetchWithProxyRetry.test.ts | 29 +++++++++++++++++++- src/services/api/fetchWithProxyRetry.ts | 10 ++++--- src/services/api/openaiShim.ts | 1 + 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/services/api/fetchWithProxyRetry.test.ts b/src/services/api/fetchWithProxyRetry.test.ts index a8c6dcd52e..0405e60705 100644 --- a/src/services/api/fetchWithProxyRetry.test.ts +++ b/src/services/api/fetchWithProxyRetry.test.ts @@ -148,11 +148,16 @@ test('fetchWithProxyRetry does not retry a 504 after the request is aborted', as const controller = new AbortController() const abortReason = new DOMException('Deadline exceeded', 'TimeoutError') let attempts = 0 + let bodyCancelled = false globalThis.fetch = (async () => { attempts++ controller.abort(abortReason) - return new Response('Gateway Timeout', { status: 504 }) + return new Response(new ReadableStream({ + cancel() { + bodyCancelled = true + }, + }), { status: 504 }) }) as unknown as FetchType await expect( @@ -163,6 +168,28 @@ test('fetchWithProxyRetry does not retry a 504 after the request is aborted', as ).rejects.toBe(abortReason) expect(attempts).toBe(1) + await Promise.resolve() + expect(bodyCancelled).toBe(true) +}) + +test('fetchWithProxyRetry honors an aborted Request signal without replaying', async () => { + const controller = new AbortController() + const abortReason = new DOMException('Deadline exceeded', 'TimeoutError') + let attempts = 0 + + globalThis.fetch = (async () => { + attempts++ + controller.abort(abortReason) + return new Response('Gateway Timeout', { status: 504 }) + }) as unknown as FetchType + + const request = new Request('https://example.com/generate', { + method: 'POST', + signal: controller.signal, + }) + + await expect(fetchWithProxyRetry(request)).rejects.toBe(abortReason) + expect(attempts).toBe(1) }) test('fetchWithProxyRetry preserves the abort reason for a generic fetch failure', async () => { diff --git a/src/services/api/fetchWithProxyRetry.ts b/src/services/api/fetchWithProxyRetry.ts index aa2484df55..33e8f979d4 100644 --- a/src/services/api/fetchWithProxyRetry.ts +++ b/src/services/api/fetchWithProxyRetry.ts @@ -29,6 +29,7 @@ export async function fetchWithProxyRetry( ): Promise { const maxAttempts = Math.max(1, options?.maxAttempts ?? 2) const fetcher = options?.fetcher ?? fetch + const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined) let lastError: unknown for (let attempt = 1; attempt <= maxAttempts; attempt++) { @@ -39,9 +40,10 @@ export async function fetchWithProxyRetry( forAnthropicAPI: options?.forAnthropicAPI, }), }) - if (init?.signal?.aborted) { + if (signal?.aborted) { + void response.body?.cancel().catch(() => {}) throw ( - init.signal.reason ?? + signal.reason ?? new DOMException('The operation was aborted.', 'AbortError') ) } @@ -61,10 +63,10 @@ export async function fetchWithProxyRetry( return response } catch (error) { lastError = error - if (init?.signal?.aborted) { + if (signal?.aborted) { throw error instanceof Error && error.name === 'AbortError' ? error - : (init.signal.reason ?? error) + : (signal.reason ?? error) } if ( attempt >= maxAttempts || diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 523991444d..d319d05ced 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -392,6 +392,7 @@ async function fetchWithHeadersDeadline( try { const response = await fetch(input, { ...attemptInit, signal }) if (signal.aborted) { + void response.body?.cancel().catch(() => {}) throw ( signal.reason ?? new DOMException('The operation was aborted.', 'AbortError') From ea0dcddbd6c861f6e297cbc73581d7851bd70dbe Mon Sep 17 00:00:00 2001 From: jatmn Date: Fri, 17 Jul 2026 17:59:49 -0700 Subject: [PATCH 17/17] fix(api): do not block proxy retries on body cancellation --- src/services/api/fetchWithProxyRetry.test.ts | 21 ++++++++++++++++++++ src/services/api/fetchWithProxyRetry.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/api/fetchWithProxyRetry.test.ts b/src/services/api/fetchWithProxyRetry.test.ts index 0405e60705..c7da2a4d2f 100644 --- a/src/services/api/fetchWithProxyRetry.test.ts +++ b/src/services/api/fetchWithProxyRetry.test.ts @@ -144,6 +144,27 @@ test('fetchWithProxyRetry retries and disables keepalive after receiving a 504 r expect((calls[1] as RequestInit).keepalive).toBe(false) }) +test('fetchWithProxyRetry retries when cancelling a discarded 504 body stalls', async () => { + let attempts = 0 + + globalThis.fetch = (async () => { + attempts++ + if (attempts === 1) { + return new Response(new ReadableStream({ + cancel() { + return new Promise(() => {}) + }, + }), { status: 504 }) + } + return new Response('ok') + }) as unknown as FetchType + + const response = await fetchWithProxyRetry('https://example.com/search') + + expect(response.status).toBe(200) + expect(attempts).toBe(2) +}) + test('fetchWithProxyRetry does not retry a 504 after the request is aborted', async () => { const controller = new AbortController() const abortReason = new DOMException('Deadline exceeded', 'TimeoutError') diff --git a/src/services/api/fetchWithProxyRetry.ts b/src/services/api/fetchWithProxyRetry.ts index 33e8f979d4..a9aa6347b7 100644 --- a/src/services/api/fetchWithProxyRetry.ts +++ b/src/services/api/fetchWithProxyRetry.ts @@ -55,7 +55,7 @@ export async function fetchWithProxyRetry( (response.status === 502 || response.status === 504) && attempt < maxAttempts ) { - await response.body?.cancel().catch(() => {}) + void response.body?.cancel().catch(() => {}) disableKeepAlive() continue }