fix: retry transient direct model failures

Direct model conversations previously stopped after the first transient network error or request timeout, including TLS setup ECONNRESET failures. Text requests now retry up to three times with cancellable backoff before visible output, while partial streamed responses are never replayed.

Release note: 直连模型遇到临时网络故障或请求超时时,会在尚未输出内容的情况下自动重试最多 3 次,并支持随时取消。
This commit is contained in:
mesalogo
2026-09-03 14:24:09 +08:00
parent f18e4abb3c
commit 74c3ec19b4
9 changed files with 718 additions and 194 deletions
+282 -38
View File
@@ -1367,6 +1367,179 @@ describe('ModelAgentRuntime', () => {
await expect(stream.next()).rejects.toThrow('upstream unavailable')
})
it('retries a nested ECONNRESET before streaming output', async () => {
vi.useFakeTimers()
try {
const connectionReset = Object.assign(
new Error(
'Client network socket disconnected before secure TLS connection was established'
),
{ code: 'ECONNRESET', port: 443 }
)
const fetcher = vi
.fn<typeof fetch>()
.mockRejectedValueOnce(
new TypeError('fetch failed', { cause: connectionReset })
)
.mockResolvedValueOnce(
new Response(createEventStream('重试后成功'), {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
events.push(event)
}
}
const completed = consume()
await vi.runAllTimersAsync()
await completed
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '重试后成功'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
vi.useRealTimers()
}
})
it('does not retry a transient stream failure after publishing output', async () => {
const connectionReset = Object.assign(
new Error('socket disconnected'),
{ code: 'ECONNRESET' }
)
let sentOutput = false
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (sentOutput) {
controller.error(connectionReset)
return
}
sentOutput = true
controller.enqueue(
new TextEncoder().encode(
[
'event: content_block_delta',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"已经显示"}}',
'',
''
].join('\n')
)
)
}
})
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(body, {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
events.push(event)
}
}
await expect(consume()).rejects.toThrow('socket disconnected')
expect(fetcher).toHaveBeenCalledOnce()
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '已经显示'
})
)
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
})
)
})
it('cancels immediately during model retry backoff', async () => {
const controller = new AbortController()
const fetcher = vi.fn<typeof fetch>(async () => {
throw Object.assign(new Error('socket disconnected'), {
code: 'ECONNRESET'
})
})
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
controller.signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status', message: 'sonnet-5 正在思考' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
}
})
const pendingRetry = stream.next()
controller.abort(new Error('用户取消'))
await expect(pendingRetry).rejects.toThrow('用户取消')
expect(fetcher).toHaveBeenCalledOnce()
})
it('parses CRLF event separators split across response chunks', async () => {
const payload = createEventStream('split CRLF').replaceAll('\n', '\r\n')
const splitAt = payload.indexOf('\r\n\r\n') + 3
@@ -1418,6 +1591,16 @@ describe('ModelAgentRuntime', () => {
it('aborts a model request that exceeds the runtime timeout', async () => {
vi.useFakeTimers()
try {
const fetcher = vi.fn<typeof fetch>(
async (_input, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
})
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
@@ -1425,35 +1608,27 @@ describe('ModelAgentRuntime', () => {
protocol: 'anthropic-messages',
authentication: 'api-key',
requestTimeoutMs: 50,
fetcher: vi.fn<typeof fetch>(
async (_input, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
})
)
fetcher
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const result = stream.next()
const assertion = expect(result).rejects.toThrow(
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
}
}
const assertion = expect(consume()).rejects.toThrow(
'模型接口请求超时'
)
await vi.advanceTimersByTimeAsync(50)
await vi.runAllTimersAsync()
await assertion
expect(fetcher).toHaveBeenCalledTimes(4)
} finally {
vi.useRealTimers()
}
@@ -1489,23 +1664,23 @@ describe('ModelAgentRuntime', () => {
)
})
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const result = stream.next()
const assertion = expect(result).rejects.toThrow(
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
}
}
const assertion = expect(consume()).rejects.toThrow(
'模型接口请求超时'
)
await vi.advanceTimersByTimeAsync(50)
await vi.runAllTimersAsync()
await assertion
expect(responseSignal?.aborted).toBe(true)
} finally {
@@ -1693,6 +1868,75 @@ describe('ModelAgentRuntime', () => {
expect(toolProvider.listTools).not.toHaveBeenCalled()
})
it('retries transient network failures for direct-model tool rounds', async () => {
vi.useFakeTimers()
try {
const fetcher = vi
.fn<typeof fetch>()
.mockRejectedValueOnce(
Object.assign(new Error('connect timeout'), {
code: 'UND_ERR_CONNECT_TIMEOUT'
})
)
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: '工具轮次重试成功'
}
}
]
})
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider: createToolProvider()
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: '使用工具能力回答',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
}
const completed = consume()
await vi.runAllTimersAsync()
await completed
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '工具轮次重试成功'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
vi.useRealTimers()
}
})
it('streams OpenAI-compatible reasoning deltas before the answer', async () => {
const stream = [
`data: ${JSON.stringify({
+354 -142
View File
@@ -188,6 +188,24 @@ const maxStreamBlockBytes = 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const defaultModelRequestTimeoutMs = 10 * 60_000
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
const retryableModelNetworkErrorCodes = new Set([
'EAI_AGAIN',
'ECONNABORTED',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTDOWN',
'EHOSTUNREACH',
'ENETDOWN',
'ENETRESET',
'ENETUNREACH',
'EPIPE',
'ETIMEDOUT',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_SOCKET'
])
const noModelTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -573,6 +591,72 @@ function normalizeRequestError(
throw error
}
function isRetryableModelRequestError(error: unknown): boolean {
const seen = new Set<unknown>()
let current = error
for (let depth = 0; depth < 8; depth += 1) {
if (
current === null ||
(typeof current !== 'object' &&
typeof current !== 'function') ||
seen.has(current)
) {
break
}
seen.add(current)
const record = current as Record<string, unknown>
if (
typeof record.code === 'string' &&
retryableModelNetworkErrorCodes.has(
record.code.toLocaleUpperCase()
)
) {
return true
}
if (typeof record.message === 'string') {
if (
record.message === '模型接口请求超时' ||
/(?:fetch failed|failed to fetch|network(?: request)? (?:error|failed)|socket disconnected|connection (?:closed|reset)|\bECONNRESET\b|\bETIMEDOUT\b)/iu.test(
record.message
)
) {
return true
}
}
current = record.cause
}
return false
}
function waitForModelRetry(
delayMs: number,
signal: AbortSignal
): Promise<void> {
signal.throwIfAborted()
return new Promise<void>((resolve, reject) => {
let settled = false
const finish = (callback: () => void): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
callback()
}
const abort = (): void => {
finish(() => reject(signal.reason))
}
const timeout = setTimeout(() => {
finish(resolve)
}, delayMs)
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
}
})
}
function parseGeneratedImage(value: unknown): {
data: string
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
@@ -1734,6 +1818,71 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
private async *retryModelStream<T>(
createAttempt: () => AsyncGenerator<RuntimeEvent, T, void>,
signal: AbortSignal,
requestId: string
): AsyncGenerator<RuntimeEvent, T, void> {
for (
let retryCount = 0;
retryCount <= modelRequestRetryDelaysMs.length;
retryCount += 1
) {
signal.throwIfAborted()
const attempt = createAttempt()
let step: IteratorResult<RuntimeEvent, T> | undefined
let outputPublished = false
let failed = false
let failure: unknown
try {
step = await attempt.next()
while (!step.done) {
if (
step.value.type === 'text' ||
step.value.type === 'reasoning'
) {
outputPublished = true
}
yield step.value
step = await attempt.next()
}
} catch (error) {
failed = true
failure = error
} finally {
if (!step?.done) {
await attempt.return(undefined as T).catch(() => undefined)
}
}
if (!failed) {
if (!step?.done) {
throw new Error('模型请求流未返回最终结果')
}
return step.value
}
if (
outputPublished ||
signal.aborted ||
retryCount >= modelRequestRetryDelaysMs.length ||
!isRetryableModelRequestError(failure)
) {
throw failure
}
yield {
requestId,
type: 'status',
message:
`模型网络请求失败,正在重试(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length})`
}
await waitForModelRetry(
modelRequestRetryDelaysMs[retryCount]!,
signal
)
}
throw new Error('模型请求重试状态无效')
}
async getStatus(): Promise<AgentRuntimeStatus> {
const imageGeneration = this.capability === 'image-generation'
return {
@@ -2577,6 +2726,29 @@ export class ModelAgentRuntime implements AgentRuntime {
anthropic: boolean,
signal: AbortSignal,
requestId: string
): AsyncGenerator<RuntimeEvent, ModelToolResponse, void> {
return yield* this.retryModelStream(
() =>
this.requestToolModelOnce(
messages,
tools,
system,
anthropic,
signal,
requestId
),
signal,
requestId
)
}
private async *requestToolModelOnce(
messages: Array<Record<string, unknown>>,
tools: ModelToolDefinition[],
system: string,
anthropic: boolean,
signal: AbortSignal,
requestId: string
): AsyncGenerator<RuntimeEvent, ModelToolResponse, void> {
const responses = this.options.protocol === 'openai-responses'
const streamOpenAIChat = !responses && !anthropic
@@ -2867,6 +3039,141 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
private async *requestTextModelOnce(
request: AgentExecutionRequest,
signal: AbortSignal,
system: string,
messages:
| AnthropicApiMessage[]
| Array<Record<string, unknown>>
): AsyncGenerator<
RuntimeEvent,
{ answer: string; usage: ModelUsageAccumulator },
void
> {
const anthropic = this.options.protocol === 'anthropic-messages'
const responses = this.options.protocol === 'openai-responses'
const modelRequest = await this.fetchWithTimeout(
this.getEndpoint(),
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(
responses
? {
model: this.options.model,
stream: true,
instructions: system,
input: messages
}
: anthropic
? {
model: this.options.model,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages
}
: {
model: this.options.model,
stream: true,
stream_options: {
include_usage: true
},
messages
}
)
},
signal
)
const response = modelRequest.response
let answer = ''
const usage = {
reported: false
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
let detail: string | undefined
try {
detail = getErrorMessage(
responseText.trim()
? JSON.parse(responseText)
: undefined
)
} catch {
detail = undefined
}
throw new Error(
detail ?? `模型接口请求失败(HTTP ${response.status})`
)
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
}
if (parsed.reasoningDelta) {
yield {
requestId: request.requestId,
type: 'reasoning',
delta: parsed.reasoningDelta
}
}
const { delta } = parsed
if (delta) {
answer += delta
yield {
requestId: request.requestId,
type: 'text',
delta
}
}
if (parsed.stopped) {
receivedStop = true
break
}
}
if (!receivedStop) {
throw new Error('模型接口流式响应意外中断')
}
if (!answer) {
throw new Error('模型接口返回了空内容')
}
return { answer, usage }
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
modelRequest.clear()
}
}
private async *requestTextModel(
request: AgentExecutionRequest,
signal: AbortSignal,
system: string,
messages:
| AnthropicApiMessage[]
| Array<Record<string, unknown>>
): AsyncGenerator<
RuntimeEvent,
{ answer: string; usage: ModelUsageAccumulator },
void
> {
return yield* this.retryModelStream(
() =>
this.requestTextModelOnce(request, signal, system, messages),
signal,
request.requestId
)
}
private async *compactAgentRunContext(
request: AgentExecutionRequest,
baseMessages: readonly Record<string, unknown>[],
@@ -3700,152 +4007,57 @@ export class ModelAgentRuntime implements AgentRuntime {
[],
anthropic || responses ? system : ''
)
const modelRequest = await this.fetchWithTimeout(
this.getEndpoint(),
const modelResponse = yield* this.requestTextModel(
request,
signal,
system,
messages
)
const { answer, usage } = modelResponse
const completedHistory = [
...(identifiedRequest.history ??
this.conversations.get(request.conversationId) ??
[]),
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(
responses
? {
model: this.options.model,
stream: true,
instructions: system,
input: messages
}
: anthropic
? {
model: this.options.model,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages
}
: {
model: this.options.model,
stream: true,
stream_options: {
include_usage: true
},
messages
}
)
id: request.currentUserMessageId,
role: 'user',
content: request.prompt
},
{
id: request.currentAssistantMessageId,
role: 'assistant',
content: answer
}
] satisfies ConversationMessage[]
this.saveConversation(request.conversationId, completedHistory)
const usageEvent = createUsageEvent(
request.requestId,
anthropic ? 'anthropic' : 'openai',
this.options.model,
usage
)
const contextMetricsEvent = this.createContextMetricsEvent(
request.requestId,
usage,
estimatedRequestTokens + estimateTextTokens(answer)
)
if (contextMetricsEvent) {
yield contextMetricsEvent
}
if (usageEvent) {
yield usageEvent
}
yield* this.finalizeConversationContext(
identifiedRequest,
completedHistory,
contextMetricsEvent?.contextTokens ??
estimatedRequestTokens + estimateTextTokens(answer),
signal
)
const response = modelRequest.response
let answer = ''
const usage = {
reported: false
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
let detail: string | undefined
try {
detail = getErrorMessage(
responseText.trim()
? JSON.parse(responseText)
: undefined
)
} catch {
detail = undefined
}
throw new Error(
detail ?? `模型接口请求失败(HTTP ${response.status})`
)
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
}
if (parsed.reasoningDelta) {
yield {
requestId: request.requestId,
type: 'reasoning',
delta: parsed.reasoningDelta
}
}
const { delta } = parsed
if (delta) {
answer += delta
yield {
requestId: request.requestId,
type: 'text',
delta
}
}
if (parsed.stopped) {
receivedStop = true
break
}
}
if (!receivedStop) {
throw new Error('模型接口流式响应意外中断')
}
if (!answer) {
throw new Error('模型接口返回了空内容')
}
const completedHistory = [
...(identifiedRequest.history ??
this.conversations.get(request.conversationId) ??
[]),
{
id: request.currentUserMessageId,
role: 'user',
content: request.prompt
},
{
id: request.currentAssistantMessageId,
role: 'assistant',
content: answer
}
] satisfies ConversationMessage[]
this.saveConversation(request.conversationId, completedHistory)
const usageEvent = createUsageEvent(
request.requestId,
anthropic ? 'anthropic' : 'openai',
this.options.model,
usage
)
const contextMetricsEvent = this.createContextMetricsEvent(
request.requestId,
usage,
estimatedRequestTokens + estimateTextTokens(answer)
)
if (contextMetricsEvent) {
yield contextMetricsEvent
}
if (usageEvent) {
yield usageEvent
}
yield* this.finalizeConversationContext(
identifiedRequest,
completedHistory,
contextMetricsEvent?.contextTokens ??
estimatedRequestTokens + estimateTextTokens(answer),
signal
)
yield {
requestId: request.requestId,
type: 'done'
}
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
modelRequest.clear()
yield {
requestId: request.requestId,
type: 'done'
}
}