Download packages/kosong/test/anthropic-errors.test.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 18.7 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/kosong/test/anthropic-errors.test.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/kosong/test/anthropic-errors.test.ts
-
curl -L -o anthropic-errors.test.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/kosong/test/anthropic-errors.test.ts
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); | |
| }); | |
| }); | |