From 2ff5710329aefd166ccf2dcbc48b0927312fd808 Mon Sep 17 00:00:00 2001 From: guanjiawei <128683929+skyguan92@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:42:14 +0800 Subject: [PATCH] fix retry Codex and OpenAI fetches via proxy-aware helper (#720) --- src/services/api/codexShim.ts | 16 ++-- src/services/api/fetchWithProxyRetry.test.ts | 86 ++++++++++++++++++++ src/services/api/fetchWithProxyRetry.ts | 44 ++++++++++ src/services/api/openaiShim.ts | 5 +- src/tools/WebSearchTool/WebSearchTool.ts | 3 +- 5 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 src/services/api/fetchWithProxyRetry.test.ts create mode 100644 src/services/api/fetchWithProxyRetry.ts diff --git a/src/services/api/codexShim.ts b/src/services/api/codexShim.ts index 4f3995dc..211bdd82 100644 --- a/src/services/api/codexShim.ts +++ b/src/services/api/codexShim.ts @@ -1,4 +1,5 @@ import { APIError } from '@anthropic-ai/sdk' +import { fetchWithProxyRetry } from './fetchWithProxyRetry.js' import type { ResolvedCodexCredentials, ResolvedProviderRequest, @@ -559,12 +560,15 @@ export async function performCodexRequest(options: { } headers.originator ??= 'openclaude' - const response = await fetch(`${options.request.baseUrl}/responses`, { - method: 'POST', - headers, - body: JSON.stringify(body), - signal: options.signal, - }) + const response = await fetchWithProxyRetry( + `${options.request.baseUrl}/responses`, + { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: options.signal, + }, + ) if (!response.ok) { const errorBody = await response.text().catch(() => 'unknown error') diff --git a/src/services/api/fetchWithProxyRetry.test.ts b/src/services/api/fetchWithProxyRetry.test.ts new file mode 100644 index 00000000..2d81df5e --- /dev/null +++ b/src/services/api/fetchWithProxyRetry.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' + +import { _resetKeepAliveForTesting } from '../../utils/proxy.js' +import { + fetchWithProxyRetry, + isRetryableFetchError, +} from './fetchWithProxyRetry.js' + +type FetchType = typeof globalThis.fetch + +const originalFetch = globalThis.fetch +const originalEnv = { + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, +} + +function restoreEnv(key: 'HTTP_PROXY' | 'HTTPS_PROXY', value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +beforeEach(() => { + process.env.HTTP_PROXY = 'http://127.0.0.1:15236' + delete process.env.HTTPS_PROXY + _resetKeepAliveForTesting() +}) + +afterEach(() => { + globalThis.fetch = originalFetch + restoreEnv('HTTP_PROXY', originalEnv.HTTP_PROXY) + restoreEnv('HTTPS_PROXY', originalEnv.HTTPS_PROXY) + _resetKeepAliveForTesting() +}) + +test('isRetryableFetchError matches Bun socket-closed failures', () => { + expect( + isRetryableFetchError( + new Error( + 'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()', + ), + ), + ).toBe(true) +}) + +test('fetchWithProxyRetry retries once with keepalive disabled after socket closure', async () => { + const calls: Array = [] + + globalThis.fetch = (async (_input, init) => { + calls.push(init) + if (calls.length === 1) { + throw new Error( + 'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()', + ) + } + return new Response('ok') + }) as FetchType + + const response = await fetchWithProxyRetry('https://example.com/search', { + method: 'POST', + }) + + expect(await response.text()).toBe('ok') + expect(calls).toHaveLength(2) + expect((calls[0] as RequestInit & { proxy?: string }).proxy).toBe( + 'http://127.0.0.1:15236', + ) + expect((calls[0] as RequestInit).keepalive).toBeUndefined() + expect((calls[1] as RequestInit).keepalive).toBe(false) +}) + +test('fetchWithProxyRetry does not retry non-network errors', async () => { + let attempts = 0 + + globalThis.fetch = (async () => { + attempts += 1 + throw new Error('400 bad request') + }) as FetchType + + await expect(fetchWithProxyRetry('https://example.com')).rejects.toThrow( + '400 bad request', + ) + expect(attempts).toBe(1) +}) diff --git a/src/services/api/fetchWithProxyRetry.ts b/src/services/api/fetchWithProxyRetry.ts new file mode 100644 index 00000000..2299a26b --- /dev/null +++ b/src/services/api/fetchWithProxyRetry.ts @@ -0,0 +1,44 @@ +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 function isRetryableFetchError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + if (error.name === 'AbortError') { + return false + } + return RETRYABLE_FETCH_ERROR_PATTERN.test(error.message) +} + +export async function fetchWithProxyRetry( + input: string | URL | Request, + init?: RequestInit, + options?: { forAnthropicAPI?: boolean; maxAttempts?: number }, +): Promise { + const maxAttempts = Math.max(1, options?.maxAttempts ?? 2) + let lastError: unknown + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fetch(input, { + ...init, + ...getProxyFetchOptions({ + forAnthropicAPI: options?.forAnthropicAPI, + }), + }) + } catch (error) { + lastError = error + if (attempt >= maxAttempts || !isRetryableFetchError(error)) { + throw error + } + disableKeepAlive() + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Fetch failed without an error object') +} diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 2bf921ea..b33acb38 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -47,6 +47,7 @@ import { type AnthropicUsage, type ShimCreateParams, } from './codexShim.js' +import { fetchWithProxyRetry } from './fetchWithProxyRetry.js' import { isLocalProviderUrl, resolveRuntimeCodexCredentials, @@ -1431,7 +1432,7 @@ class OpenAIShimMessages { const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1 let response: Response | undefined for (let attempt = 0; attempt < maxAttempts; attempt++) { - response = await fetch(chatCompletionsUrl, fetchInit) + response = await fetchWithProxyRetry(chatCompletionsUrl, fetchInit) if (response.ok) { return response } @@ -1504,7 +1505,7 @@ class OpenAIShimMessages { } } - const responsesResponse = await fetch(responsesUrl, { + const responsesResponse = await fetchWithProxyRetry(responsesUrl, { method: 'POST', headers, body: JSON.stringify(responsesBody), diff --git a/src/tools/WebSearchTool/WebSearchTool.ts b/src/tools/WebSearchTool/WebSearchTool.ts index bdec1d13..6a17510f 100644 --- a/src/tools/WebSearchTool/WebSearchTool.ts +++ b/src/tools/WebSearchTool/WebSearchTool.ts @@ -9,6 +9,7 @@ import { z } from 'zod/v4' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' import { queryModelWithStreaming } from '../../services/api/claude.js' import { collectCodexCompletedResponse } from '../../services/api/codexShim.js' +import { fetchWithProxyRetry } from '../../services/api/fetchWithProxyRetry.js' import { resolveCodexApiCredentials, resolveProviderRequest, @@ -314,7 +315,7 @@ async function runCodexWebSearch( body.reasoning = request.reasoning } - const response = await fetch(`${request.baseUrl}/responses`, { + const response = await fetchWithProxyRetry(`${request.baseUrl}/responses`, { method: 'POST', headers: { 'Content-Type': 'application/json',