Skip to content

Commit 82feb2e

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@f08f3fc2ef1801eac4556c008ea998ea4b636629
1 parent 28b3ad5 commit 82feb2e

7 files changed

Lines changed: 222 additions & 6 deletions

File tree

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/src/util/__tests__/error-api-details.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from 'bun:test'
22

3-
import { extractApiErrorDetails, isFetchIdleTimeoutError } from '../error'
3+
import {
4+
extractApiErrorDetails,
5+
isFetchIdleTimeoutError,
6+
isTransientNetworkError,
7+
} from '../error'
48

59
describe('extractApiErrorDetails', () => {
610
it('extracts structured details from nested retry errors', () => {
@@ -125,3 +129,62 @@ describe('isFetchIdleTimeoutError', () => {
125129
expect(isFetchIdleTimeoutError('The operation timed out.')).toBe(false)
126130
})
127131
})
132+
133+
describe('isTransientNetworkError', () => {
134+
it('detects the Bun socket-close message', () => {
135+
expect(
136+
isTransientNetworkError(
137+
new Error(
138+
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
139+
),
140+
),
141+
).toBe(true)
142+
})
143+
144+
it('detects transient network error codes', () => {
145+
const error = new Error('request failed') as Error & { code: string }
146+
error.code = 'ECONNRESET'
147+
expect(isTransientNetworkError(error)).toBe(true)
148+
149+
const bunError = new Error('request failed') as Error & { code: string }
150+
bunError.code = 'ConnectionClosed'
151+
expect(isTransientNetworkError(bunError)).toBe(true)
152+
})
153+
154+
it('detects the undici "fetch failed" TypeError', () => {
155+
expect(isTransientNetworkError(new TypeError('fetch failed'))).toBe(true)
156+
})
157+
158+
it('detects a socket error nested inside an AI SDK RetryError wrapper', () => {
159+
const socketError = new Error(
160+
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
161+
) as Error & { code: string }
162+
socketError.code = 'ECONNRESET'
163+
const retryError = new Error('Failed after 3 attempts.') as Error & {
164+
errors: unknown[]
165+
}
166+
retryError.errors = [socketError]
167+
expect(isTransientNetworkError(retryError)).toBe(true)
168+
})
169+
170+
it('detects a socket error in a cause chain', () => {
171+
const inner = new Error('read ECONNRESET') as Error & { code: string }
172+
inner.code = 'ECONNRESET'
173+
const outer = new Error('Cannot connect to API')
174+
;(outer as Error & { cause: unknown }).cause = inner
175+
expect(isTransientNetworkError(outer)).toBe(true)
176+
})
177+
178+
it('returns false for unrelated errors', () => {
179+
expect(isTransientNetworkError(new Error('Internal Server Error'))).toBe(
180+
false,
181+
)
182+
expect(isTransientNetworkError(undefined)).toBe(false)
183+
expect(isTransientNetworkError('fetch failed')).toBe(false)
184+
const serverError = new Error('Bad Request') as Error & {
185+
statusCode: number
186+
}
187+
serverError.statusCode = 400
188+
expect(isTransientNetworkError(serverError)).toBe(false)
189+
})
190+
})

common/src/util/error.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,73 @@ export const FETCH_IDLE_TIMEOUT_USER_MESSAGE =
394394
'The server sends a heartbeat every 30 seconds while responses stream, so this usually means the connection was silently dropped in transit (VPN, proxy, firewall, or flaky network) rather than a server outage.\n\n' +
395395
'Things to try: retry your message, check your network/VPN/proxy, or switch networks if it keeps happening.'
396396

397+
/**
398+
* Substrings of error messages that indicate the TCP connection died in
399+
* transit (as opposed to the server returning an error response). Bun's fetch
400+
* throws a plain Error with "The socket connection was closed unexpectedly..."
401+
* and code ECONNRESET/ConnectionClosed; undici throws TypeError "fetch failed".
402+
*/
403+
const TRANSIENT_NETWORK_ERROR_MESSAGE_PATTERNS = [
404+
'socket connection was closed unexpectedly',
405+
'fetch failed',
406+
'failed to fetch',
407+
'network connection was lost',
408+
]
409+
410+
const TRANSIENT_NETWORK_ERROR_CODES = new Set([
411+
'ECONNRESET',
412+
'ECONNREFUSED',
413+
'EPIPE',
414+
'ETIMEDOUT',
415+
// Bun-specific fetch error codes
416+
'ConnectionClosed',
417+
'ConnectionRefused',
418+
'FailedToOpenSocket',
419+
])
420+
421+
/**
422+
* Detects transient connection-level failures (socket closed/reset, connection
423+
* refused, etc.) where no HTTP response was received. These are safe to retry
424+
* and should be shown to the user as a connectivity problem instead of a raw
425+
* runtime error with a stack trace. Walks AI SDK RetryError wrappers and
426+
* cause chains.
427+
*/
428+
export function isTransientNetworkError(error: unknown): boolean {
429+
for (const candidate of getApiErrorCandidates(error)) {
430+
if (!candidate || typeof candidate !== 'object') continue
431+
const { message, code } = candidate as {
432+
message?: unknown
433+
code?: unknown
434+
}
435+
if (typeof code === 'string' && TRANSIENT_NETWORK_ERROR_CODES.has(code)) {
436+
return true
437+
}
438+
if (typeof message === 'string') {
439+
const lower = message.toLowerCase()
440+
if (
441+
TRANSIENT_NETWORK_ERROR_MESSAGE_PATTERNS.some((pattern) =>
442+
lower.includes(pattern),
443+
)
444+
) {
445+
return true
446+
}
447+
}
448+
}
449+
return false
450+
}
451+
452+
/**
453+
* User-facing explanation for a dropped connection. The raw runtime message
454+
* ("The socket connection was closed unexpectedly. For more information, pass
455+
* `verbose: true`...") plus a stack trace reads like a crash; in practice the
456+
* connection to the server was cut mid-request, which is transient and safe
457+
* to retry.
458+
*/
459+
export const TRANSIENT_NETWORK_ERROR_USER_MESSAGE =
460+
'Connection interrupted: the connection to the server was closed unexpectedly, even after retrying.\n\n' +
461+
'This is usually a transient issue — a flaky network, VPN/proxy, or the server briefly under heavy load.\n\n' +
462+
'Your progress is saved. Please try sending your message again.'
463+
397464
// Extended error properties that various libraries add to Error objects
398465
interface ExtendedErrorProperties {
399466
status?: number

packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,42 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
11361136
expect(result.output.message).not.toBe('The operation timed out.')
11371137
}
11381138
})
1139+
1140+
it('should explain dropped socket connections instead of showing the raw runtime message', async () => {
1141+
const llmOnlyTemplate = {
1142+
...mockTemplate,
1143+
handleSteps: undefined,
1144+
}
1145+
1146+
const localAgentTemplates = {
1147+
'test-agent': llmOnlyTemplate,
1148+
}
1149+
1150+
// Bun's fetch throws a plain Error with this message (and code
1151+
// ECONNRESET/ConnectionClosed) when the TCP connection is dropped.
1152+
loopAgentStepsBaseParams.promptAiSdkStream = async function* () {
1153+
const socketError = new Error(
1154+
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
1155+
) as Error & { code: string }
1156+
socketError.code = 'ECONNRESET'
1157+
throw socketError
1158+
}
1159+
1160+
const result = await loopAgentSteps({
1161+
...loopAgentStepsBaseParams,
1162+
agentType: 'test-agent',
1163+
localAgentTemplates,
1164+
})
1165+
1166+
expect(result.output.type).toBe('error')
1167+
if (result.output.type === 'error') {
1168+
expect(result.output.message).toContain('Connection interrupted')
1169+
expect(result.output.message).not.toContain('Agent run error:')
1170+
expect(result.output.message).not.toContain(
1171+
'pass `verbose: true` in the second argument to fetch()',
1172+
)
1173+
}
1174+
})
11391175
})
11401176

11411177
describe('steering (drainSteeringMessages)', () => {

packages/agent-runtime/src/run-agent-step.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import { buildArray } from '@codebuff/common/util/array'
1212
import {
1313
AbortError,
1414
FETCH_IDLE_TIMEOUT_USER_MESSAGE,
15+
TRANSIENT_NETWORK_ERROR_USER_MESSAGE,
1516
extractApiErrorDetails,
1617
getErrorObject,
1718
isAbortError,
1819
isFetchIdleTimeoutError,
20+
isTransientNetworkError,
1921
} from '@codebuff/common/util/error'
2022
import { serializeCacheDebugCorrelation } from '@codebuff/common/util/cache-debug'
2123
import { systemMessage, userMessage } from '@codebuff/common/util/messages'
@@ -1200,10 +1202,13 @@ export async function loopAgentSteps(
12001202

12011203
const apiErrorDetails = extractApiErrorDetails(error)
12021204
const isIdleTimeout = isFetchIdleTimeoutError(error)
1205+
const isNetworkError = !isIdleTimeout && isTransientNetworkError(error)
12031206
const hasServerMessage = apiErrorDetails.message !== undefined
12041207
let fallbackMessage: string
12051208
if (isIdleTimeout) {
12061209
fallbackMessage = FETCH_IDLE_TIMEOUT_USER_MESSAGE
1210+
} else if (isNetworkError) {
1211+
fallbackMessage = TRANSIENT_NETWORK_ERROR_USER_MESSAGE
12071212
} else if (error instanceof Error) {
12081213
const includeStack =
12091214
apiErrorDetails.statusCode === undefined && error.stack
@@ -1236,7 +1241,7 @@ export async function loopAgentSteps(
12361241
output: {
12371242
type: 'error',
12381243
message:
1239-
hasServerMessage || isIdleTimeout
1244+
hasServerMessage || isIdleTimeout || isNetworkError
12401245
? errorMessage
12411246
: 'Agent run error: ' + errorMessage,
12421247
...(statusCode !== undefined && { statusCode }),

sdk/src/impl/model-provider.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ import {
1717
isOpenAIProviderModel,
1818
toOpenAIModelId,
1919
} from '@codebuff/common/constants/chatgpt-oauth'
20+
import { isTransientNetworkError } from '@codebuff/common/util/error'
2021
import {
2122
OpenAICompatibleChatLanguageModel,
2223
VERSION,
2324
} from '@codebuff/llm-providers/openai-compatible'
25+
import { APICallError } from 'ai'
2426

2527
import { getWebsiteUrl } from '../constants'
2628
import { getValidChatGptOAuthCredentials } from '../credentials'
@@ -191,6 +193,41 @@ function createOpenAIOAuthModel(
191193
})
192194
}
193195

196+
/**
197+
* Wrap global fetch so transient connection failures (socket closed/reset,
198+
* connection refused) are rethrown as retryable APICallErrors.
199+
*
200+
* Bun's fetch throws these as plain Errors ("The socket connection was closed
201+
* unexpectedly...", code ECONNRESET/ConnectionClosed), which the AI SDK does
202+
* not recognize as retryable — it only auto-retries APICallError with
203+
* isRetryable=true. Marking them retryable lets streamText's built-in
204+
* exponential backoff (default 2 retries) absorb brief server/network blips
205+
* instead of failing the whole agent run.
206+
*/
207+
function fetchWithRetryableNetworkErrors(
208+
...args: Parameters<typeof globalThis.fetch>
209+
): ReturnType<typeof globalThis.fetch> {
210+
return globalThis.fetch(...args).catch((error: unknown) => {
211+
if (isTransientNetworkError(error)) {
212+
const input = args[0]
213+
const url =
214+
typeof input === 'string'
215+
? input
216+
: input instanceof URL
217+
? input.toString()
218+
: input.url
219+
throw new APICallError({
220+
message: error instanceof Error ? error.message : String(error),
221+
cause: error,
222+
url,
223+
requestBodyValues: {},
224+
isRetryable: true,
225+
})
226+
}
227+
throw error
228+
})
229+
}
230+
194231
/**
195232
* Create a model that routes through the Codebuff backend.
196233
* This is the existing behavior - requests go to Codebuff backend which forwards to OpenRouter.
@@ -257,7 +294,9 @@ function createCodebuffBackendModel(
257294
},
258295
}),
259296
},
260-
fetch: undefined,
297+
// Cast: Bun's fetch type also declares a `preconnect` helper, but the AI
298+
// SDK only ever invokes fetch as a plain function.
299+
fetch: fetchWithRetryableNetworkErrors as typeof globalThis.fetch,
261300
includeUsage: undefined,
262301
supportsStructuredOutputs: true,
263302
})

sdk/src/run.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import { clientToolCallSchema } from '@codebuff/common/tools/list'
2121
import { AgentOutputSchema } from '@codebuff/common/types/session-state'
2222
import {
2323
FETCH_IDLE_TIMEOUT_USER_MESSAGE,
24+
TRANSIENT_NETWORK_ERROR_USER_MESSAGE,
2425
extractApiErrorDetails,
2526
isFetchIdleTimeoutError,
27+
isTransientNetworkError,
2628
} from '@codebuff/common/util/error'
2729
import { cloneDeep } from 'lodash'
2830

@@ -644,9 +646,11 @@ async function runOnce({
644646
}).catch((error) => {
645647
let errorMessage = isFetchIdleTimeoutError(error)
646648
? FETCH_IDLE_TIMEOUT_USER_MESSAGE
647-
: error instanceof Error
648-
? error.message
649-
: String(error ?? '')
649+
: isTransientNetworkError(error)
650+
? TRANSIENT_NETWORK_ERROR_USER_MESSAGE
651+
: error instanceof Error
652+
? error.message
653+
: String(error ?? '')
650654
const apiErrorDetails = extractApiErrorDetails(error)
651655
const statusCode = apiErrorDetails.statusCode ?? getErrorStatusCode(error)
652656
const {

0 commit comments

Comments
 (0)