kimi-code / packages /kosong /test /anthropic-errors.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
18.7 kB
import {
APIConnectionError,
APIContextOverflowError,
APIProviderQuotaExhaustedError,
APIProviderRateLimitError,
APIStatusError,
APITimeoutError,
ChatProviderError,
isRetryableGenerateError,
} from '#/errors';
import { convertAnthropicError, AnthropicChatProvider } from '#/providers/anthropic';
import { classifyKimiQuotaError } from '#/providers/kimi-errors';
import {
APIConnectionError as AnthropicConnectionError,
APIConnectionTimeoutError as AnthropicTimeoutError,
APIError as AnthropicAPIError,
AnthropicError,
APIUserAbortError as AnthropicUserAbortError,
AuthenticationError as AnthropicAuthenticationError,
RateLimitError as AnthropicRateLimitError,
} from '@anthropic-ai/sdk';
import { describe, it, expect, vi } from 'vitest';
describe('convertAnthropicError', () => {
it('APIConnectionTimeoutError -> APITimeoutError (not misclassified as connection)', () => {
const err = new AnthropicTimeoutError({ message: 'timed out' });
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(APITimeoutError);
// Must NOT be a plain APIConnectionError
expect(result.constructor).toBe(APITimeoutError);
});
it('APIConnectionError -> APIConnectionError', () => {
const err = new AnthropicConnectionError({ message: 'connection refused' });
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(APIConnectionError);
});
it('APIError with status -> APIStatusError', () => {
const err = AnthropicAPIError.generate(
502,
{ type: 'error', error: { type: 'api_error', message: 'bad gateway' } },
'bad gateway',
new Headers(),
);
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(APIStatusError);
expect((result as APIStatusError).statusCode).toBe(502);
});
it('context overflow APIError -> APIContextOverflowError', () => {
const err = AnthropicAPIError.generate(
422,
{
type: 'error',
error: {
type: 'invalid_request_error',
message: 'prompt is too long: 210000 tokens exceeds the maximum',
},
},
'prompt is too long: 210000 tokens exceeds the maximum',
new Headers(),
);
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(APIContextOverflowError);
expect((result as APIContextOverflowError).statusCode).toBe(422);
});
it('AuthenticationError -> APIStatusError with 401', () => {
const err = new AnthropicAuthenticationError(
401,
{ type: 'error', error: { type: 'authentication_error', message: 'invalid key' } },
'invalid key',
new Headers(),
);
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(APIStatusError);
expect((result as APIStatusError).statusCode).toBe(401);
});
it('RateLimitError -> APIProviderRateLimitError with 429', () => {
const err = new AnthropicRateLimitError(
429,
{ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } },
'rate limited',
new Headers(),
);
const result = convertAnthropicError(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 = AnthropicAPIError.generate(
429,
{ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } },
'rate limited',
new Headers({ 'retry-after': '7' }),
);
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(APIProviderRateLimitError);
expect((result as APIProviderRateLimitError).retryAfterMs).toBe(7_000);
});
it('ignores a non-integer (HTTP-date) retry-after header, leaving retryAfterMs null', () => {
const err = AnthropicAPIError.generate(
429,
{ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } },
'rate limited',
new Headers({ 'retry-after': 'Wed, 21 Oct 2026 07:28:00 GMT' }),
);
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(APIProviderRateLimitError);
expect((result as APIProviderRateLimitError).retryAfterMs).toBeNull();
});
it('generic AnthropicError -> ChatProviderError', () => {
const err = new AnthropicError('something went wrong');
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(ChatProviderError);
expect(result.message).toContain('something went wrong');
});
it('plain Error -> ChatProviderError', () => {
const err = new Error('unexpected');
const result = convertAnthropicError(err);
expect(result).toBeInstanceOf(ChatProviderError);
expect(result.message).toContain('unexpected');
});
it('non-Error value -> ChatProviderError', () => {
const result = convertAnthropicError('string error');
expect(result).toBeInstanceOf(ChatProviderError);
expect(result.message).toContain('string error');
});
it('APIUserAbortError throws the standard abort DOMException instead of being classified', () => {
// A user cancellation must never be converted into (or returned as) a
// retryable provider error: the guard at the very front of the
// classification chain throws the standard abort shape.
const err = new AnthropicUserAbortError({ message: 'aborted by user' });
let thrown: unknown;
try {
convertAnthropicError(err);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(DOMException);
expect((thrown as DOMException).name).toBe('AbortError');
expect(isRetryableGenerateError(thrown)).toBe(false);
});
it('bare AbortError DOMException throws the standard abort DOMException', () => {
const err = new DOMException('The operation was aborted.', 'AbortError');
let thrown: unknown;
try {
convertAnthropicError(err);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(DOMException);
expect((thrown as DOMException).name).toBe('AbortError');
expect(isRetryableGenerateError(thrown)).toBe(false);
});
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 Anthropic 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 = convertAnthropicError(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 Anthropic SDK error and carries no
// usable HTTP status, so convertAnthropicError 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 = convertAnthropicError(new Error('something completely unrelated'));
expect(result.constructor).toBe(ChatProviderError);
expect(isRetryableGenerateError(result)).toBe(true);
});
});
describe('non-stream error propagation', () => {
function createNonStreamProvider(): AnthropicChatProvider {
return new AnthropicChatProvider({
model: 'k25',
apiKey: 'test-key',
defaultMaxTokens: 1024,
stream: false,
});
}
it('APIConnectionTimeoutError during generate is converted', async () => {
const provider = createNonStreamProvider();
const sdkError = new AnthropicTimeoutError({ message: 'stream timed out' });
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
await expect(
provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
),
).rejects.toThrow(APITimeoutError);
});
it('APIConnectionError during generate is converted', async () => {
const provider = createNonStreamProvider();
const sdkError = new AnthropicConnectionError({ message: 'connection reset' });
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
await expect(
provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
),
).rejects.toThrow(APIConnectionError);
});
it('APIError with status during generate is converted to APIStatusError', async () => {
const provider = createNonStreamProvider();
const sdkError = AnthropicAPIError.generate(
500,
{ type: 'error', error: { type: 'api_error', message: 'internal error' } },
'internal error',
new Headers(),
);
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
await expect(
provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
),
).rejects.toThrow(APIStatusError);
});
it('RateLimitError during generate is converted to APIProviderRateLimitError(429)', async () => {
const provider = createNonStreamProvider();
const sdkError = new AnthropicRateLimitError(
429,
{ type: 'error', error: { type: 'rate_limit_error', message: 'too many requests' } },
'too many requests',
new Headers(),
);
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
try {
await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
expect.unreachable('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(APIProviderRateLimitError);
expect((error as APIProviderRateLimitError).statusCode).toBe(429);
}
});
it('AuthenticationError during generate is converted to APIStatusError(401)', async () => {
const provider = createNonStreamProvider();
const sdkError = new AnthropicAuthenticationError(
401,
{ type: 'error', error: { type: 'authentication_error', message: 'invalid' } },
'invalid',
new Headers(),
);
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(sdkError);
try {
await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
expect.unreachable('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(APIStatusError);
expect((error as APIStatusError).statusCode).toBe(401);
}
});
});
describe('stream error propagation', () => {
function createStreamProvider(): AnthropicChatProvider {
return new AnthropicChatProvider({
model: 'k25',
apiKey: 'test-key',
defaultMaxTokens: 1024,
stream: true,
});
}
function makeErrorStream(error: Error) {
return {
async *[Symbol.asyncIterator]() {
yield {
type: 'message_start',
message: { id: 'msg_err', usage: { input_tokens: 0 } },
};
throw error;
},
};
}
it('APIConnectionTimeoutError during stream iteration is converted', async () => {
const provider = createStreamProvider();
const sdkError = new AnthropicTimeoutError({ message: 'stream timed out' });
(provider as any)._client.messages.create = vi
.fn()
.mockResolvedValue(makeErrorStream(sdkError)) as never;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts: unknown[] = [];
await expect(
(async () => {
for await (const part of result) {
parts.push(part);
}
})(),
).rejects.toThrow(APITimeoutError);
});
it('APIConnectionError during stream iteration is converted', async () => {
const provider = createStreamProvider();
const sdkError = new AnthropicConnectionError({ message: 'connection reset' });
(provider as any)._client.messages.create = vi
.fn()
.mockResolvedValue(makeErrorStream(sdkError)) as never;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
await expect(
(async () => {
for await (const _ of result) {
void _;
}
})(),
).rejects.toThrow(APIConnectionError);
});
it('APIError with status during stream iteration is converted to APIStatusError', async () => {
const provider = createStreamProvider();
const sdkError = AnthropicAPIError.generate(
500,
{ type: 'error', error: { type: 'api_error', message: 'internal error' } },
'internal error',
new Headers(),
);
(provider as any)._client.messages.create = vi
.fn()
.mockResolvedValue(makeErrorStream(sdkError)) as never;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
await expect(
(async () => {
for await (const _ of result) {
void _;
}
})(),
).rejects.toThrow(APIStatusError);
});
it('RateLimitError during stream iteration is converted to APIProviderRateLimitError(429)', async () => {
const provider = createStreamProvider();
const sdkError = new AnthropicRateLimitError(
429,
{ type: 'error', error: { type: 'rate_limit_error', message: 'too many requests' } },
'too many requests',
new Headers(),
);
(provider as any)._client.messages.create = vi
.fn()
.mockResolvedValue(makeErrorStream(sdkError)) as never;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
try {
for await (const _ of result) {
void _;
}
expect.unreachable('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(APIProviderRateLimitError);
expect((error as APIProviderRateLimitError).statusCode).toBe(429);
}
});
it('AuthenticationError during stream iteration is converted to APIStatusError(401)', async () => {
const provider = createStreamProvider();
const sdkError = new AnthropicAuthenticationError(
401,
{ type: 'error', error: { type: 'authentication_error', message: 'invalid' } },
'invalid',
new Headers(),
);
(provider as any)._client.messages.create = vi
.fn()
.mockResolvedValue(makeErrorStream(sdkError)) as never;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
try {
for await (const _ of result) {
void _;
}
expect.unreachable('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(APIStatusError);
expect((error as APIStatusError).statusCode).toBe(401);
}
});
it('undici TypeError("terminated") during stream iteration -> retryable APIConnectionError', async () => {
// 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.
const provider = createStreamProvider();
(provider as any)._client.messages.create = vi
.fn()
.mockResolvedValue(makeErrorStream(new TypeError('terminated'))) as never;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
let caught: unknown;
try {
for await (const _ of result) {
void _;
}
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(APIConnectionError);
expect(isRetryableGenerateError(caught)).toBe(true);
});
});
describe('convertAnthropicError: quota-exhausted 429 via the convertError hook', () => {
const QUOTA_BODY = {
type: 'error',
error: {
type: 'exceeded_current_quota_error',
message:
'Your account org-0123456789abcdef <ak-test> is suspended due to insufficient balance, please recharge your account or check your plan and billing details',
},
};
function quota429(): unknown {
return AnthropicAPIError.generate(429, QUOTA_BODY, 'Too many requests', new Headers());
}
it('keeps vendor quota signals a rate limit without the vendor hook', () => {
const result = convertAnthropicError(quota429());
expect(result).toBeInstanceOf(APIProviderRateLimitError);
expect(isRetryableGenerateError(result)).toBe(true);
});
it('classifies the Kimi quota body as quota-exhausted through the hook', () => {
const result = convertAnthropicError(quota429(), classifyKimiQuotaError);
expect(result).toBeInstanceOf(APIProviderQuotaExhaustedError);
expect(isRetryableGenerateError(result)).toBe(false);
});
it('passes already-converted errors through without re-consulting the hook', () => {
const calls: unknown[] = [];
const converted = new APIProviderQuotaExhaustedError('already classified');
const result = convertAnthropicError(converted, (error) => {
calls.push(error);
return new ChatProviderError('re-classified');
});
expect(result).toBe(converted);
expect(calls).toHaveLength(0);
});
it('the provider threads options.convertError to its generate catch', async () => {
const provider = new AnthropicChatProvider({
model: 'k25',
apiKey: 'test-key',
defaultMaxTokens: 1024,
stream: false,
convertError: classifyKimiQuotaError,
});
(provider as any)._client.messages.create = vi.fn().mockRejectedValue(quota429());
await expect(
provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
),
).rejects.toThrow(APIProviderQuotaExhaustedError);
});
});