fix retry Codex and OpenAI fetches via proxy-aware helper (#720)

This commit is contained in:
guanjiawei
2026-04-16 21:42:14 +08:00
committed by GitHub
parent d6f5130c20
commit 2ff5710329
5 changed files with 145 additions and 9 deletions

View File

@@ -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')

View File

@@ -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<RequestInit | undefined> = []
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)
})

View File

@@ -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<Response> {
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')
}

View File

@@ -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),

View File

@@ -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',