buster/packages/ai/tests/utils/retry/ai-sdk-error-mocks.test.ts

496 lines
17 KiB
TypeScript

import {
APICallError,
EmptyResponseBodyError,
InvalidArgumentError,
InvalidDataContentError,
InvalidMessageRoleError,
InvalidPromptError,
InvalidResponseDataError,
InvalidToolArgumentsError,
JSONParseError,
LoadAPIKeyError,
NoContentGeneratedError,
NoSuchModelError,
NoSuchProviderError,
NoSuchToolError,
RetryError,
ToolExecutionError,
TypeValidationError,
UnsupportedFunctionalityError,
} from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { detectRetryableError } from '../../../src/utils/retry/retry-agent-stream';
import { RetryWithHealingError } from '../../../src/utils/retry/retry-error';
import {
createRetryOnErrorHandler,
createUserFriendlyErrorMessage,
extractDetailedErrorMessage,
} from '../../../src/utils/retry/retry-helpers';
import type { WorkflowContext } from '../../../src/utils/retry/types';
describe('AI SDK Error Mocks - Comprehensive Error Handling', () => {
describe('API Call Errors', () => {
it('should handle APICallError with 429 rate limit', async () => {
const error = new APICallError({
message: 'Rate limit exceeded',
statusCode: 429,
responseHeaders: {
'retry-after': '60',
},
responseBody: 'Too many requests',
url: 'https://api.openai.com/v1/chat/completions',
requestBodyValues: {
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello' }],
},
cause: undefined,
isRetryable: true,
});
const context: WorkflowContext = { currentStep: 'analyst' };
const retryableError = detectRetryableError(error, context);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('rate-limit');
expect(retryableError?.healingMessage.content).toContain('Please wait 60 seconds');
});
it('should handle APICallError with 503 service unavailable', async () => {
const error = new APICallError({
message: 'Service unavailable',
statusCode: 503,
responseHeaders: {},
responseBody: 'Service temporarily unavailable',
url: 'https://api.anthropic.com/v1/messages',
requestBodyValues: {},
cause: undefined,
isRetryable: true,
});
const context: WorkflowContext = { currentStep: 'think-and-prep' };
const retryableError = detectRetryableError(error, context);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('server-error');
expect(retryableError?.healingMessage.content).toContain('temporarily unavailable');
});
it('should handle APICallError with network timeout', async () => {
const error = new APICallError({
message: 'Network request failed',
statusCode: undefined,
responseHeaders: {},
responseBody: undefined,
url: 'https://api.openai.com/v1/chat/completions',
requestBodyValues: {},
cause: new Error('ETIMEDOUT'),
isRetryable: true,
});
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('network-timeout');
expect(retryableError?.healingMessage.content).toContain('Network connection error');
});
it('should handle onError callback with APICallError', async () => {
const handler = createRetryOnErrorHandler({
retryCount: 1,
maxRetries: 5,
workflowContext: { currentStep: 'analyst' },
});
const error = new APICallError({
message: 'Internal server error',
statusCode: 500,
responseHeaders: {},
responseBody: 'Server error',
url: 'https://api.example.com',
requestBodyValues: {},
cause: undefined,
isRetryable: true,
});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
await expect(handler({ error })).rejects.toThrow(RetryWithHealingError);
consoleErrorSpy.mockRestore();
consoleInfoSpy.mockRestore();
});
});
describe('Tool-Related Errors', () => {
it('should handle NoSuchToolError with available tools', () => {
const error = new NoSuchToolError({
toolName: 'createMetrics',
availableTools: ['sequentialThinking', 'executeSql', 'submitThoughts'],
});
const context: WorkflowContext = { currentStep: 'think-and-prep' };
const retryableError = detectRetryableError(error, context);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('no-such-tool');
expect(retryableError?.healingMessage.role).toBe('tool');
expect(retryableError?.healingMessage.content[0]).toMatchObject({
type: 'tool-result',
toolName: 'createMetrics',
result: {
error: expect.stringContaining('Tool "createMetrics" is not available'),
},
});
});
it('should handle InvalidToolArgumentsError with Zod validation', () => {
const error = new InvalidToolArgumentsError({
toolName: 'executeSql',
toolCallId: 'call_123',
args: { query: 123 }, // Wrong type
cause: {
errors: [{ path: ['query'], message: 'Expected string, received number' }],
},
});
// Cast to Error with name property for detection
(error as any).name = 'AI_InvalidToolArgumentsError';
(error as any).toolCallId = 'call_123'; // Ensure toolCallId is accessible
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('invalid-tool-arguments');
expect(retryableError?.healingMessage.content[0]).toMatchObject({
type: 'tool-result',
toolCallId: 'call_123',
toolName: 'executeSql',
result: {
error: expect.stringContaining('query: Expected string, received number'),
},
});
});
it('should handle ToolExecutionError', () => {
const error = new ToolExecutionError({
toolName: 'readFile',
toolCallId: 'call_456',
message: 'File not found',
cause: new Error('ENOENT: no such file or directory'),
});
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('invalid-tool-arguments');
expect(retryableError?.healingMessage.content[0]).toMatchObject({
type: 'tool-result',
toolCallId: 'call_456',
toolName: 'readFile',
result: {
error: expect.stringContaining('Tool execution failed'),
},
});
});
});
describe('Response and Parsing Errors', () => {
it('should handle EmptyResponseBodyError', () => {
const error = new EmptyResponseBodyError({
message: 'Empty response body',
});
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('empty-response');
expect(retryableError?.healingMessage.content).toBe('Please continue.');
});
it('should handle JSONParseError', () => {
const error = new JSONParseError({
message: 'Invalid JSON',
text: '{"incomplete": ',
cause: new SyntaxError('Unexpected end of JSON input'),
});
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('json-parse-error');
expect(retryableError?.healingMessage.content).toContain('issue with the response format');
});
it('should handle NoContentGeneratedError', () => {
const error = new NoContentGeneratedError({
message: 'No content generated',
});
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('empty-response');
expect(retryableError?.healingMessage.content).toBe('Please continue.');
});
it('should handle InvalidResponseDataError', () => {
const error = new InvalidResponseDataError({
message: 'Invalid response data',
data: { unexpected: 'format' },
});
const detailedMessage = extractDetailedErrorMessage(error);
expect(detailedMessage).toContain('Invalid response data');
});
});
describe('Validation Errors', () => {
it('should handle InvalidArgumentError', () => {
const error = new InvalidArgumentError({
argument: 'messages',
message: 'Messages cannot be empty',
});
const detailedMessage = extractDetailedErrorMessage(error);
expect(detailedMessage).toContain('Messages cannot be empty');
});
it('should handle InvalidDataContentError', () => {
const error = new InvalidDataContentError({
message: 'Invalid data content',
content: { type: 'unknown' },
});
const detailedMessage = extractDetailedErrorMessage(error);
expect(detailedMessage).toContain('Invalid data content');
});
it('should handle InvalidMessageRoleError', () => {
const error = new InvalidMessageRoleError({
message: "Invalid role 'bot'",
role: 'bot',
});
const detailedMessage = extractDetailedErrorMessage(error);
expect(detailedMessage).toContain("Invalid role 'bot'");
});
it('should handle InvalidPromptError', () => {
const error = new InvalidPromptError({
message: 'Prompt is required',
prompt: '',
});
const detailedMessage = extractDetailedErrorMessage(error);
expect(detailedMessage).toContain('Prompt is required');
});
it('should handle TypeValidationError', () => {
const error = new TypeValidationError({
message: 'Type validation failed',
value: { invalid: true },
});
const detailedMessage = extractDetailedErrorMessage(error);
expect(detailedMessage).toContain('Type validation failed');
});
});
describe('Configuration Errors (Non-Retryable)', () => {
it('should NOT retry LoadAPIKeyError', () => {
const error = new LoadAPIKeyError({
message: 'API key not found',
});
(error as any).name = 'AI_LoadAPIKeyError';
const retryableError = detectRetryableError(error);
expect(retryableError).toBeNull();
});
it('should NOT retry LoadSettingError', () => {
// Create a mock error with the expected name
const error = new Error('Setting not found');
(error as any).name = 'AI_LoadSettingError';
(error as any).setting = 'baseURL';
const retryableError = detectRetryableError(error);
expect(retryableError).toBeNull();
});
it('should NOT retry NoSuchModelError', () => {
const error = new NoSuchModelError({
message: 'Model not found',
modelId: 'gpt-5',
modelType: 'languageModel',
});
(error as any).name = 'AI_NoSuchModelError';
const retryableError = detectRetryableError(error);
expect(retryableError).toBeNull();
});
it('should NOT retry NoSuchProviderError', () => {
const error = new NoSuchProviderError({
message: 'Provider not found',
providerId: 'unknown-provider',
availableProviders: ['openai', 'anthropic'],
});
(error as any).name = 'AI_NoSuchProviderError';
const retryableError = detectRetryableError(error);
expect(retryableError).toBeNull();
});
it('should NOT retry UnsupportedFunctionalityError', () => {
const error = new UnsupportedFunctionalityError({
message: 'Functionality not supported',
functionality: 'streaming',
});
(error as any).name = 'AI_UnsupportedFunctionalityError';
const retryableError = detectRetryableError(error);
expect(retryableError).toBeNull();
});
it('should NOT retry TooManyEmbeddingValuesForCallError', () => {
// Create a mock error with the expected name
const error = new Error('Too many embedding values');
(error as any).name = 'AI_TooManyEmbeddingValuesForCallError';
(error as any).modelId = 'text-embedding-ada-002';
(error as any).provider = 'openai';
(error as any).numValues = 10000;
const retryableError = detectRetryableError(error);
expect(retryableError).toBeNull();
});
});
describe('Complex Error Scenarios', () => {
it('should handle RetryError with nested error', () => {
const nestedError = new APICallError({
message: 'Service unavailable',
statusCode: 503,
responseHeaders: {},
responseBody: 'Service down',
url: 'https://api.example.com',
requestBodyValues: {},
cause: undefined,
isRetryable: true,
});
const error = new RetryError({
message: 'Failed after 3 retries',
reason: 'maxRetriesExceeded',
errors: [nestedError, nestedError, nestedError],
lastError: nestedError,
});
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('server-error');
});
it('should handle InvalidModelIdError', () => {
// Create a mock error since InvalidModelIdError might not be exported
const error = new Error('Invalid model ID format');
(error as any).modelId = 'invalid@model#id';
const detailedMessage = extractDetailedErrorMessage(error);
expect(detailedMessage).toContain('Invalid model ID format');
});
it('should handle generic "No tool calls generated" error', () => {
const error = new Error('No tool calls generated');
const retryableError = detectRetryableError(error);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('empty-response');
expect(retryableError?.healingMessage.content).toBe('Please continue.');
});
it('should handle unknown errors with stack trace', () => {
const error = new Error('Unknown error occurred');
error.stack = `Error: Unknown error occurred
at Object.<anonymous> (/path/to/file.js:10:15)
at Module._compile (node:internal/modules/cjs/loader:1126:14)`;
const context: WorkflowContext = { currentStep: 'analyst' };
const retryableError = detectRetryableError(error, context);
expect(retryableError).not.toBeNull();
expect(retryableError?.type).toBe('unknown-error');
expect(retryableError?.healingMessage.content).toContain('Unknown error occurred');
expect(retryableError?.healingMessage.content).toContain('at Object.<anonymous>');
});
});
describe('User-Friendly Error Messages', () => {
it('should create user-friendly message for database errors', () => {
const error = new Error('Cannot connect to DATABASE_URL');
const message = createUserFriendlyErrorMessage(error);
expect(message).toBe('Unable to connect to the analysis service. Please try again later.');
});
it('should create user-friendly message for API errors', () => {
const error = new Error('API request failed');
const message = createUserFriendlyErrorMessage(error);
expect(message).toBe(
'The analysis service is temporarily unavailable. Please try again in a few moments.'
);
});
it('should create user-friendly message for model errors', () => {
const error = new Error('model not found');
const message = createUserFriendlyErrorMessage(error);
expect(message).toBe(
'The analysis service is temporarily unavailable. Please try again in a few moments.'
);
});
it('should create generic message for unknown errors', () => {
const error = new Error('Something went wrong');
const message = createUserFriendlyErrorMessage(error);
expect(message).toBe(
'Something went wrong during the analysis. Please try again or contact support if the issue persists.'
);
});
});
describe('Complete onError Handler Flow', () => {
it('should handle full retry flow with healing', async () => {
const handler = createRetryOnErrorHandler({
retryCount: 2,
maxRetries: 5,
workflowContext: { currentStep: 'analyst' },
});
const error = new NoSuchToolError({
toolName: 'invalidTool',
availableTools: ['tool1', 'tool2'],
});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
let thrownError: any;
try {
await handler({ error });
} catch (e) {
thrownError = e;
}
expect(thrownError).toBeInstanceOf(RetryWithHealingError);
expect(thrownError.retryableError.type).toBe('no-such-tool');
expect(thrownError.retryableError.healingMessage.content[0].result.error).toContain(
'Tool "invalidTool" is not available'
);
consoleErrorSpy.mockRestore();
consoleInfoSpy.mockRestore();
});
});
});