mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-26 01:25:35 +00:00
* feat(retry): harden LLM API fault tolerance against 429/overload - retry more transient errors: 408/409/429/5xx/529, an embedded upstream status_code=429 in OpenAI Responses stream errors, and unclassified provider errors as a last-resort fallback - honor server Retry-After (parsed into APIStatusError.retryAfterMs by the OpenAI and Anthropic providers); chatWithRetry prefers it over its backoff - align app-level backoff with claude-code (500ms base, 32s cap, factor 2, up to 25% jitter) so high-attempt configs ride out multi-minute overload - emit a turn.step.retrying meta line in -p --output-format stream-json
410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
import {
|
|
APIConnectionError,
|
|
APIContextOverflowError,
|
|
APIProviderRateLimitError,
|
|
APIStatusError,
|
|
APITimeoutError,
|
|
ChatProviderError,
|
|
isRetryableGenerateError,
|
|
} from '#/errors';
|
|
import type { ContentPart } from '#/message';
|
|
import {
|
|
convertContentPart,
|
|
convertOpenAIError,
|
|
reasoningEffortToThinkingEffort,
|
|
thinkingEffortToReasoningEffort,
|
|
} from '#/providers/openai-common';
|
|
import { OpenAILegacyChatProvider, OpenAILegacyStreamedMessage } from '#/providers/openai-legacy';
|
|
import {
|
|
APIError as OpenAIAPIError,
|
|
APIConnectionError as OpenAIConnectionError,
|
|
APIConnectionTimeoutError as OpenAITimeoutError,
|
|
APIUserAbortError as OpenAIUserAbortError,
|
|
} from 'openai';
|
|
import { describe, it, expect } from 'vitest';
|
|
describe('OpenAI client creation', () => {
|
|
it('does not inject max_retries into OpenAI client', () => {
|
|
// The OpenAI constructor is called with apiKey and baseURL only —
|
|
// we verify that the provider does not set max_retries.
|
|
const provider = new OpenAILegacyChatProvider({
|
|
model: 'gpt-4.1',
|
|
apiKey: 'test-key',
|
|
baseUrl: 'https://example.com/v1',
|
|
});
|
|
|
|
const client = (provider as any)._client as Record<string, unknown>;
|
|
expect((client as unknown as Record<string, unknown>)['maxRetries']).not.toBe(0);
|
|
});
|
|
});
|
|
describe('convertOpenAIError: base APIError mapping', () => {
|
|
const cases: Array<{ message: string; expectedType: typeof ChatProviderError; id: string }> = [
|
|
{
|
|
message: 'Network connection lost.',
|
|
expectedType: APIConnectionError,
|
|
id: 'network_connection_lost',
|
|
},
|
|
{ message: 'Connection error.', expectedType: APIConnectionError, id: 'connection_error' },
|
|
{ message: 'network error', expectedType: APIConnectionError, id: 'network_error' },
|
|
{ message: 'disconnected from server', expectedType: APIConnectionError, id: 'disconnected' },
|
|
{
|
|
message: 'connection reset by peer',
|
|
expectedType: APIConnectionError,
|
|
id: 'connection_reset_by_peer',
|
|
},
|
|
{
|
|
message: 'connection closed unexpectedly',
|
|
expectedType: APIConnectionError,
|
|
id: 'connection_closed_unexpectedly',
|
|
},
|
|
{ message: 'Request timed out.', expectedType: APITimeoutError, id: 'request_timed_out' },
|
|
{ message: 'timed out', expectedType: APITimeoutError, id: 'timed_out' },
|
|
// Timeout must take priority over network when both patterns match.
|
|
{
|
|
message: 'connection timed out',
|
|
expectedType: APITimeoutError,
|
|
id: 'connection_timed_out_timeout_priority',
|
|
},
|
|
{
|
|
message: 'Something completely unrelated',
|
|
expectedType: ChatProviderError,
|
|
id: 'unrelated_error',
|
|
},
|
|
{
|
|
message: 'Internal server error',
|
|
expectedType: ChatProviderError,
|
|
id: 'internal_server_error',
|
|
},
|
|
// Bare "reset"/"closed" must NOT match — they are too broad
|
|
{
|
|
message: 'Your session has been reset',
|
|
expectedType: ChatProviderError,
|
|
id: 'bare_reset_no_match',
|
|
},
|
|
{
|
|
message: 'Stream closed by server due to policy violation',
|
|
expectedType: ChatProviderError,
|
|
id: 'bare_closed_no_match',
|
|
},
|
|
];
|
|
|
|
for (const { message, expectedType, id } of cases) {
|
|
it(`classifies "${id}": ${message}`, () => {
|
|
// Base APIError with no status and no body (transport-layer failure)
|
|
const err = new OpenAIAPIError(undefined, undefined, message, undefined);
|
|
const result = convertOpenAIError(err);
|
|
expect(result).toBeInstanceOf(expectedType);
|
|
});
|
|
}
|
|
});
|
|
describe('convertOpenAIError: existing provider errors', () => {
|
|
it('preserves an existing ChatProviderError instance', () => {
|
|
const err = new APIStatusError(401, 'Unauthorized', 'req-401');
|
|
|
|
expect(convertOpenAIError(err)).toBe(err);
|
|
});
|
|
});
|
|
describe('convertOpenAIError: context overflow', () => {
|
|
it('normalizes context overflow status errors', () => {
|
|
const err = new OpenAIAPIError(413, undefined, 'Context length exceeded', undefined);
|
|
const result = convertOpenAIError(err);
|
|
expect(result).toBeInstanceOf(APIContextOverflowError);
|
|
expect((result as APIContextOverflowError).statusCode).toBe(413);
|
|
});
|
|
});
|
|
describe('convertOpenAIError: provider rate limit', () => {
|
|
it('normalizes HTTP 429 status errors to APIProviderRateLimitError', () => {
|
|
const err = new OpenAIAPIError(429, undefined, 'Too many requests', new Headers());
|
|
const result = convertOpenAIError(err);
|
|
expect(result).toBeInstanceOf(APIProviderRateLimitError);
|
|
expect((result as APIProviderRateLimitError).statusCode).toBe(429);
|
|
});
|
|
|
|
it('reads an integer retry-after header (seconds) onto the rate-limit error', () => {
|
|
const err = new OpenAIAPIError(
|
|
429,
|
|
undefined,
|
|
'Too many requests',
|
|
new Headers({ 'retry-after': '12' }),
|
|
);
|
|
const result = convertOpenAIError(err);
|
|
expect(result).toBeInstanceOf(APIProviderRateLimitError);
|
|
expect((result as APIProviderRateLimitError).retryAfterMs).toBe(12_000);
|
|
});
|
|
|
|
it('ignores a non-integer (HTTP-date) retry-after header, leaving retryAfterMs null', () => {
|
|
const err = new OpenAIAPIError(
|
|
429,
|
|
undefined,
|
|
'Too many requests',
|
|
new Headers({ 'retry-after': 'Wed, 21 Oct 2026 07:28:00 GMT' }),
|
|
);
|
|
const result = convertOpenAIError(err);
|
|
expect(result).toBeInstanceOf(APIProviderRateLimitError);
|
|
expect((result as APIProviderRateLimitError).retryAfterMs).toBeNull();
|
|
});
|
|
});
|
|
describe('convertOpenAIError: subclass errors still match first', () => {
|
|
it('APIConnectionError matches its own case', () => {
|
|
const connErr = new OpenAIConnectionError({ message: 'Connection error.' });
|
|
const result = convertOpenAIError(connErr);
|
|
expect(result).toBeInstanceOf(APIConnectionError);
|
|
});
|
|
|
|
it('APIConnectionTimeoutError matches as timeout', () => {
|
|
const timeoutErr = new OpenAITimeoutError({ message: 'Request timed out.' });
|
|
const result = convertOpenAIError(timeoutErr);
|
|
expect(result).toBeInstanceOf(APITimeoutError);
|
|
});
|
|
});
|
|
describe('convertOpenAIError: APIError with body skips heuristic', () => {
|
|
it('does not heuristically reclassify when error has a body', () => {
|
|
// SSE error events carry a body — they must NOT be reclassified
|
|
// even if the message contains network keywords.
|
|
const err = new OpenAIAPIError(
|
|
undefined,
|
|
{ error: { message: 'Connection limit exceeded', type: 'server_error' } },
|
|
'Connection limit exceeded',
|
|
undefined,
|
|
);
|
|
const result = convertOpenAIError(err);
|
|
// Should NOT be APIConnectionError despite "Connection" in message
|
|
expect(result.constructor).toBe(ChatProviderError);
|
|
});
|
|
});
|
|
describe('convertOpenAIError: subclass errors fall through', () => {
|
|
it('APIUserAbortError is not heuristically reclassified', () => {
|
|
// APIUserAbortError is a subclass of APIError (not exact APIError),
|
|
// so the heuristic branch should not apply even with network keywords.
|
|
const err = new OpenAIUserAbortError({ message: 'connection aborted by user' });
|
|
const result = convertOpenAIError(err);
|
|
// Should fall through to generic handling, not become APIConnectionError
|
|
expect(result.constructor).toBe(ChatProviderError);
|
|
});
|
|
});
|
|
describe('OpenAI streaming error propagation', () => {
|
|
it('base APIError("Network connection lost.") during streaming becomes APIConnectionError', async () => {
|
|
// Simulates: streaming for ~33 minutes, then SSE connection drops
|
|
// and the SDK raises openai.APIError("Network connection lost.")
|
|
async function* failingStream(): AsyncGenerator<never> {
|
|
throw new OpenAIAPIError(undefined, undefined, 'Network connection lost.', undefined);
|
|
// Make this an async generator (unreachable)
|
|
yield undefined as never;
|
|
}
|
|
|
|
const msg = new OpenAILegacyStreamedMessage(
|
|
failingStream() as AsyncIterable<never>,
|
|
true,
|
|
undefined,
|
|
);
|
|
|
|
await expect(async () => {
|
|
for await (const _ of msg) {
|
|
void _;
|
|
}
|
|
}).rejects.toThrow(APIConnectionError);
|
|
|
|
// Verify the message is preserved
|
|
await expect(async () => {
|
|
async function* failingStream2(): AsyncGenerator<never> {
|
|
throw new OpenAIAPIError(undefined, undefined, 'Network connection lost.', undefined);
|
|
yield undefined as never;
|
|
}
|
|
const msg2 = new OpenAILegacyStreamedMessage(
|
|
failingStream2() as AsyncIterable<never>,
|
|
true,
|
|
undefined,
|
|
);
|
|
for await (const _ of msg2) {
|
|
void _;
|
|
}
|
|
}).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 base ChatProviderError, now retryable via fallback', () => {
|
|
// An unrelated raw Error is NOT an OpenAI SDK error and carries no usable
|
|
// HTTP status, so convertOpenAIError wraps it as a base ChatProviderError
|
|
// (constructor check guards that typing). The fallback safety net in
|
|
// isRetryableGenerateError then treats such unclassified provider failures
|
|
// as transient — retry beats failing the run on the first blip.
|
|
const result = convertOpenAIError(new Error('something completely unrelated'));
|
|
|
|
expect(result.constructor).toBe(ChatProviderError);
|
|
expect(isRetryableGenerateError(result)).toBe(true);
|
|
});
|
|
});
|
|
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({
|
|
type: 'text',
|
|
text: 'hi',
|
|
});
|
|
});
|
|
|
|
it('returns null for ThinkPart (handled separately as reasoning_content)', () => {
|
|
expect(convertContentPart({ type: 'think', think: 'reasoning' })).toBeNull();
|
|
});
|
|
|
|
it('converts ImageURLPart without id', () => {
|
|
expect(
|
|
convertContentPart({ type: 'image_url', imageUrl: { url: 'https://ex/img.png' } }),
|
|
).toEqual({ type: 'image_url', image_url: { url: 'https://ex/img.png' } });
|
|
});
|
|
|
|
it('converts ImageURLPart with id', () => {
|
|
expect(
|
|
convertContentPart({
|
|
type: 'image_url',
|
|
imageUrl: { url: 'https://ex/img.png', id: 'img-1' },
|
|
}),
|
|
).toEqual({ type: 'image_url', image_url: { url: 'https://ex/img.png', id: 'img-1' } });
|
|
});
|
|
|
|
it('converts AudioURLPart without id', () => {
|
|
expect(
|
|
convertContentPart({ type: 'audio_url', audioUrl: { url: 'https://ex/a.mp3' } }),
|
|
).toEqual({ type: 'audio_url', audio_url: { url: 'https://ex/a.mp3' } });
|
|
});
|
|
|
|
it('converts AudioURLPart with id', () => {
|
|
expect(
|
|
convertContentPart({
|
|
type: 'audio_url',
|
|
audioUrl: { url: 'https://ex/a.mp3', id: 'a-1' },
|
|
}),
|
|
).toEqual({ type: 'audio_url', audio_url: { url: 'https://ex/a.mp3', id: 'a-1' } });
|
|
});
|
|
|
|
it('converts VideoURLPart without id', () => {
|
|
expect(
|
|
convertContentPart({ type: 'video_url', videoUrl: { url: 'https://ex/v.mp4' } }),
|
|
).toEqual({ type: 'video_url', video_url: { url: 'https://ex/v.mp4' } });
|
|
});
|
|
|
|
it('converts VideoURLPart with id', () => {
|
|
expect(
|
|
convertContentPart({
|
|
type: 'video_url',
|
|
videoUrl: { url: 'https://ex/v.mp4', id: 'v-1' },
|
|
}),
|
|
).toEqual({ type: 'video_url', video_url: { url: 'https://ex/v.mp4', id: 'v-1' } });
|
|
});
|
|
|
|
it('throws on unknown content part type', () => {
|
|
// Force an invalid type to exercise the defensive branch.
|
|
const bogus = { type: 'bogus', text: 'x' } as unknown as ContentPart;
|
|
expect(() => convertContentPart(bogus)).toThrow(/Unknown content part type/);
|
|
});
|
|
});
|
|
describe('thinkingEffortToReasoningEffort', () => {
|
|
it('maps off -> undefined', () => {
|
|
expect(thinkingEffortToReasoningEffort('off')).toBeUndefined();
|
|
});
|
|
it('maps low -> "low"', () => {
|
|
expect(thinkingEffortToReasoningEffort('low')).toBe('low');
|
|
});
|
|
it('maps medium -> "medium"', () => {
|
|
expect(thinkingEffortToReasoningEffort('medium')).toBe('medium');
|
|
});
|
|
it('maps high -> "high"', () => {
|
|
expect(thinkingEffortToReasoningEffort('high')).toBe('high');
|
|
});
|
|
it('maps xhigh -> "xhigh"', () => {
|
|
expect(thinkingEffortToReasoningEffort('xhigh')).toBe('xhigh');
|
|
});
|
|
it('maps max -> "xhigh"', () => {
|
|
expect(thinkingEffortToReasoningEffort('max')).toBe('xhigh');
|
|
});
|
|
it('normalizes unknown effort to undefined', () => {
|
|
// Unknown / model-declared efforts (including 'on') are tolerated: the
|
|
// provider omits reasoning_effort and lets the model use its own default.
|
|
expect(thinkingEffortToReasoningEffort('extreme' as never)).toBeUndefined();
|
|
});
|
|
});
|
|
describe('reasoningEffortToThinkingEffort', () => {
|
|
it('returns null for undefined', () => {
|
|
const effort: string | undefined = undefined;
|
|
expect(reasoningEffortToThinkingEffort(effort)).toBeNull();
|
|
});
|
|
it('maps "low" -> low', () => {
|
|
expect(reasoningEffortToThinkingEffort('low')).toBe('low');
|
|
});
|
|
it('maps "minimal" -> low (alias)', () => {
|
|
expect(reasoningEffortToThinkingEffort('minimal')).toBe('low');
|
|
});
|
|
it('maps "medium" -> medium', () => {
|
|
expect(reasoningEffortToThinkingEffort('medium')).toBe('medium');
|
|
});
|
|
it('maps "high" -> high', () => {
|
|
expect(reasoningEffortToThinkingEffort('high')).toBe('high');
|
|
});
|
|
it('maps "xhigh" -> xhigh', () => {
|
|
expect(reasoningEffortToThinkingEffort('xhigh')).toBe('xhigh');
|
|
});
|
|
it('maps "max" -> xhigh (alias)', () => {
|
|
expect(reasoningEffortToThinkingEffort('max')).toBe('xhigh');
|
|
});
|
|
it('maps "none" -> off', () => {
|
|
expect(reasoningEffortToThinkingEffort('none')).toBe('off');
|
|
});
|
|
it('unknown values fall back to off', () => {
|
|
expect(reasoningEffortToThinkingEffort('ultra')).toBe('off');
|
|
});
|
|
});
|
|
describe('convertOpenAIError: non-Error values', () => {
|
|
it('wraps a plain string as ChatProviderError', () => {
|
|
const result = convertOpenAIError('something went sideways');
|
|
expect(result.constructor).toBe(ChatProviderError);
|
|
expect(result.message).toContain('something went sideways');
|
|
});
|
|
|
|
it('wraps a plain Error as ChatProviderError', () => {
|
|
const result = convertOpenAIError(new Error('plain error'));
|
|
expect(result.constructor).toBe(ChatProviderError);
|
|
expect(result.message).toContain('plain error');
|
|
});
|
|
});
|