Skip to content

Commit 2ff5710

Browse files
authored
fix retry Codex and OpenAI fetches via proxy-aware helper (Gitlawb#720)
1 parent d6f5130 commit 2ff5710

File tree

5 files changed

+145
-9
lines changed

5 files changed

+145
-9
lines changed

src/services/api/codexShim.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { APIError } from '@anthropic-ai/sdk'
2+
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
23
import type {
34
ResolvedCodexCredentials,
45
ResolvedProviderRequest,
@@ -559,12 +560,15 @@ export async function performCodexRequest(options: {
559560
}
560561
headers.originator ??= 'openclaude'
561562

562-
const response = await fetch(`${options.request.baseUrl}/responses`, {
563-
method: 'POST',
564-
headers,
565-
body: JSON.stringify(body),
566-
signal: options.signal,
567-
})
563+
const response = await fetchWithProxyRetry(
564+
`${options.request.baseUrl}/responses`,
565+
{
566+
method: 'POST',
567+
headers,
568+
body: JSON.stringify(body),
569+
signal: options.signal,
570+
},
571+
)
568572

569573
if (!response.ok) {
570574
const errorBody = await response.text().catch(() => 'unknown error')
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, beforeEach, expect, test } from 'bun:test'
2+
3+
import { _resetKeepAliveForTesting } from '../../utils/proxy.js'
4+
import {
5+
fetchWithProxyRetry,
6+
isRetryableFetchError,
7+
} from './fetchWithProxyRetry.js'
8+
9+
type FetchType = typeof globalThis.fetch
10+
11+
const originalFetch = globalThis.fetch
12+
const originalEnv = {
13+
HTTP_PROXY: process.env.HTTP_PROXY,
14+
HTTPS_PROXY: process.env.HTTPS_PROXY,
15+
}
16+
17+
function restoreEnv(key: 'HTTP_PROXY' | 'HTTPS_PROXY', value: string | undefined): void {
18+
if (value === undefined) {
19+
delete process.env[key]
20+
} else {
21+
process.env[key] = value
22+
}
23+
}
24+
25+
beforeEach(() => {
26+
process.env.HTTP_PROXY = 'http://127.0.0.1:15236'
27+
delete process.env.HTTPS_PROXY
28+
_resetKeepAliveForTesting()
29+
})
30+
31+
afterEach(() => {
32+
globalThis.fetch = originalFetch
33+
restoreEnv('HTTP_PROXY', originalEnv.HTTP_PROXY)
34+
restoreEnv('HTTPS_PROXY', originalEnv.HTTPS_PROXY)
35+
_resetKeepAliveForTesting()
36+
})
37+
38+
test('isRetryableFetchError matches Bun socket-closed failures', () => {
39+
expect(
40+
isRetryableFetchError(
41+
new Error(
42+
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
43+
),
44+
),
45+
).toBe(true)
46+
})
47+
48+
test('fetchWithProxyRetry retries once with keepalive disabled after socket closure', async () => {
49+
const calls: Array<RequestInit | undefined> = []
50+
51+
globalThis.fetch = (async (_input, init) => {
52+
calls.push(init)
53+
if (calls.length === 1) {
54+
throw new Error(
55+
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
56+
)
57+
}
58+
return new Response('ok')
59+
}) as FetchType
60+
61+
const response = await fetchWithProxyRetry('https://example.com/search', {
62+
method: 'POST',
63+
})
64+
65+
expect(await response.text()).toBe('ok')
66+
expect(calls).toHaveLength(2)
67+
expect((calls[0] as RequestInit & { proxy?: string }).proxy).toBe(
68+
'http://127.0.0.1:15236',
69+
)
70+
expect((calls[0] as RequestInit).keepalive).toBeUndefined()
71+
expect((calls[1] as RequestInit).keepalive).toBe(false)
72+
})
73+
74+
test('fetchWithProxyRetry does not retry non-network errors', async () => {
75+
let attempts = 0
76+
77+
globalThis.fetch = (async () => {
78+
attempts += 1
79+
throw new Error('400 bad request')
80+
}) as FetchType
81+
82+
await expect(fetchWithProxyRetry('https://example.com')).rejects.toThrow(
83+
'400 bad request',
84+
)
85+
expect(attempts).toBe(1)
86+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { disableKeepAlive, getProxyFetchOptions } from '../../utils/proxy.js'
2+
3+
const RETRYABLE_FETCH_ERROR_PATTERN =
4+
/socket connection was closed unexpectedly|ECONNRESET|EPIPE|socket hang up|Connection reset by peer|fetch failed/i
5+
6+
export function isRetryableFetchError(error: unknown): boolean {
7+
if (!(error instanceof Error)) {
8+
return false
9+
}
10+
if (error.name === 'AbortError') {
11+
return false
12+
}
13+
return RETRYABLE_FETCH_ERROR_PATTERN.test(error.message)
14+
}
15+
16+
export async function fetchWithProxyRetry(
17+
input: string | URL | Request,
18+
init?: RequestInit,
19+
options?: { forAnthropicAPI?: boolean; maxAttempts?: number },
20+
): Promise<Response> {
21+
const maxAttempts = Math.max(1, options?.maxAttempts ?? 2)
22+
let lastError: unknown
23+
24+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
25+
try {
26+
return await fetch(input, {
27+
...init,
28+
...getProxyFetchOptions({
29+
forAnthropicAPI: options?.forAnthropicAPI,
30+
}),
31+
})
32+
} catch (error) {
33+
lastError = error
34+
if (attempt >= maxAttempts || !isRetryableFetchError(error)) {
35+
throw error
36+
}
37+
disableKeepAlive()
38+
}
39+
}
40+
41+
throw lastError instanceof Error
42+
? lastError
43+
: new Error('Fetch failed without an error object')
44+
}

src/services/api/openaiShim.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
type AnthropicUsage,
4848
type ShimCreateParams,
4949
} from './codexShim.js'
50+
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
5051
import {
5152
isLocalProviderUrl,
5253
resolveRuntimeCodexCredentials,
@@ -1431,7 +1432,7 @@ class OpenAIShimMessages {
14311432
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
14321433
let response: Response | undefined
14331434
for (let attempt = 0; attempt < maxAttempts; attempt++) {
1434-
response = await fetch(chatCompletionsUrl, fetchInit)
1435+
response = await fetchWithProxyRetry(chatCompletionsUrl, fetchInit)
14351436
if (response.ok) {
14361437
return response
14371438
}
@@ -1504,7 +1505,7 @@ class OpenAIShimMessages {
15041505
}
15051506
}
15061507

1507-
const responsesResponse = await fetch(responsesUrl, {
1508+
const responsesResponse = await fetchWithProxyRetry(responsesUrl, {
15081509
method: 'POST',
15091510
headers,
15101511
body: JSON.stringify(responsesBody),

src/tools/WebSearchTool/WebSearchTool.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { z } from 'zod/v4'
99
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
1010
import { queryModelWithStreaming } from '../../services/api/claude.js'
1111
import { collectCodexCompletedResponse } from '../../services/api/codexShim.js'
12+
import { fetchWithProxyRetry } from '../../services/api/fetchWithProxyRetry.js'
1213
import {
1314
resolveCodexApiCredentials,
1415
resolveProviderRequest,
@@ -314,7 +315,7 @@ async function runCodexWebSearch(
314315
body.reasoning = request.reasoning
315316
}
316317

317-
const response = await fetch(`${request.baseUrl}/responses`, {
318+
const response = await fetchWithProxyRetry(`${request.baseUrl}/responses`, {
318319
method: 'POST',
319320
headers: {
320321
'Content-Type': 'application/json',

0 commit comments

Comments
 (0)