fix(kosong): retry when a response stream is terminated mid-flight (#201)

A mid-stream SSE drop surfaces as a raw undici `TypeError: terminated`, which was classified as a non-retryable generic error and failed the turn on the first attempt. Route raw transport-layer errors through the connection-error heuristic so a dropped stream becomes a retryable APIConnectionError and is retried transparently. User aborts (ESC) are unaffected — the retry loop checks the abort signal before retrying.

Related to #149.
This commit is contained in:
qer 2026-05-29 15:18:55 +08:00 committed by GitHub
parent 5159af341c
commit 3da4daeade
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 140 additions and 2 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code": patch
---
Automatically retry when a model response stream is dropped mid-flight (a `terminated` error) instead of failing the turn.

View file

@ -0,0 +1,70 @@
import { APIConnectionError, emptyUsage, isRetryableGenerateError } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import type { LLM, LLMChatParams, LLMChatResponse } from '#/loop/llm';
import { chatWithRetry } from '#/loop/retry';
function okResponse(): LLMChatResponse {
return { toolCalls: [], usage: emptyUsage() };
}
function makeInput(
llm: LLM,
signal: AbortSignal,
): Parameters<typeof chatWithRetry>[0] {
return {
llm,
params: { messages: [], tools: [], signal },
dispatchEvent: async () => {},
turnId: 't',
currentStep: 1,
stepUuid: 'u',
};
}
describe('chatWithRetry: terminated stream drops', () => {
it('retries an APIConnectionError("terminated") and succeeds on a later attempt', async () => {
// A mid-stream `terminated` is classified as a retryable APIConnectionError,
// so an intermittent connection drop should be recovered transparently.
let calls = 0;
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
calls += 1;
if (calls === 1) throw new APIConnectionError('terminated');
return okResponse();
},
};
const response = await chatWithRetry(makeInput(llm, new AbortController().signal));
expect(calls).toBe(2);
expect(response).toEqual(okResponse());
});
it('does NOT retry when the signal is aborted (user ESC), surfacing a clean AbortError', async () => {
// Even though `terminated` is retryable, a user-aborted request must never
// be retried: the abort signal is checked before any retry, so it surfaces
// as an AbortError rather than a provider error.
let calls = 0;
const ac = new AbortController();
ac.abort();
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
calls += 1;
throw new APIConnectionError('terminated');
},
};
await expect(chatWithRetry(makeInput(llm, ac.signal))).rejects.toMatchObject({
name: 'AbortError',
});
expect(calls).toBe(1);
});
});

View file

@ -84,7 +84,11 @@ export function toolToOpenAI(tool: Tool): OpenAIToolParam {
},
};
}
const NETWORK_RE = /network|connection|connect|disconnect/i;
// `terminated` is the undici signature for an SSE/HTTP body stream that is
// dropped mid-flight (common with Node's native fetch on long reasoning
// streams). It surfaces as a raw `TypeError: terminated`, so it must be
// recognized here as a transport-layer connection failure.
const NETWORK_RE = /network|connection|connect|disconnect|terminated/i;
const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i;
function classifyBaseApiError(message: string): ChatProviderError {
@ -129,8 +133,13 @@ export function convertOpenAIError(error: unknown): ChatProviderError {
if (error instanceof OpenAIError) {
return new ChatProviderError(`Error: ${error.message}`);
}
// Raw, non-SDK errors (e.g. undici's `TypeError: terminated` raised when a
// streaming response body is dropped mid-flight) never get wrapped by the
// OpenAI SDK during stream iteration. Route them through the same
// transport-layer heuristic so genuine connection failures become
// retryable instead of fatal generic errors.
if (error instanceof Error) {
return new ChatProviderError(`Error: ${error.message}`);
return classifyBaseApiError(error.message);
}
return new ChatProviderError(`Error: ${String(error)}`);
}

View file

@ -4,6 +4,7 @@ import {
APIStatusError,
APITimeoutError,
ChatProviderError,
isRetryableGenerateError,
} from '#/errors';
import type { ContentPart } from '#/message';
import {
@ -186,6 +187,58 @@ describe('OpenAI streaming error propagation', () => {
}).rejects.toThrow(/Network connection lost/);
});
});
describe('convertOpenAIError: raw transport-layer stream errors', () => {
it('classifies undici TypeError("terminated") as a retryable APIConnectionError', () => {
// Node v24 + undici raises a raw `TypeError: terminated` when an SSE
// response stream is dropped mid-flight. It is NOT an OpenAI SDK error,
// so it falls into the generic Error branch — but it is a transport-layer
// connection failure and must be retryable like any dropped connection.
const err = new TypeError('terminated');
(err as { cause?: unknown }).cause = new Error('other side closed');
const result = convertOpenAIError(err);
expect(result).toBeInstanceOf(APIConnectionError);
expect(isRetryableGenerateError(result)).toBe(true);
});
it('still wraps an unrelated raw Error as a non-retryable ChatProviderError', () => {
const result = convertOpenAIError(new Error('something completely unrelated'));
expect(result.constructor).toBe(ChatProviderError);
expect(isRetryableGenerateError(result)).toBe(false);
});
});
describe('OpenAI streaming: undici terminated mid-stream', () => {
it('a stream that throws TypeError("terminated") rejects with retryable APIConnectionError', async () => {
// Simulates the real-world failure: the SSE stream drops mid-flight and
// undici raises a raw `TypeError: terminated` from inside the for-await
// loop. The provider must surface a retryable APIConnectionError so the
// loop retries instead of failing the turn outright.
async function* terminatedStream(): AsyncGenerator<never> {
throw new TypeError('terminated');
yield undefined as never;
}
const msg = new OpenAILegacyStreamedMessage(
terminatedStream() as AsyncIterable<never>,
true,
undefined,
);
let caught: unknown;
try {
for await (const _ of msg) {
void _;
}
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(APIConnectionError);
expect(isRetryableGenerateError(caught)).toBe(true);
});
});
describe('convertContentPart', () => {
it('converts TextPart to OpenAI text content part', () => {
expect(convertContentPart({ type: 'text', text: 'hi' })).toEqual({