automaker / apps /server /tests /unit /lib /provider-error-handler.test.ts
Leon4gr45's picture
Upload folder using huggingface_hub (part 3)
a31c0fe verified
Raw History Blame Contribute Delete
6.22 kB
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
classifyError,
createErrorResponse,
createRetryHandler,
ErrorSeverity,
ErrorType,
getUserFriendlyErrorMessage,
isAuthenticationError,
isBillingError,
isRateLimitError,
isRetryableError,
logError,
ProviderErrorHandler,
} from '../../../src/lib/error-handler.js';
const logger = vi.hoisted(() => ({ error: vi.fn(), debug: vi.fn() }));
vi.mock('@automaker/utils', () => ({ createLogger: () => logger }));
afterEach(() => vi.useRealTimers());
describe('provider error policy', () => {
it.each([
['invalid_api_key', ErrorType.AUTHENTICATION, false],
['insufficient credit', ErrorType.BILLING, false],
['429 too many requests', ErrorType.RATE_LIMIT, true],
['ECONNREFUSED', ErrorType.NETWORK, true],
['operation aborted', ErrorType.TIMEOUT, true],
['403 forbidden', ErrorType.PERMISSION, false],
['command not found', ErrorType.CLI_NOT_FOUND, false],
['unknown model', ErrorType.MODEL_NOT_SUPPORTED, false],
['503 internal server error', ErrorType.SERVER_ERROR, true],
['an unfamiliar failure', ErrorType.UNKNOWN, true],
])('classifies %s with the correct retry policy', (message, type, retryable) => {
expect(classifyError(new Error(message), 'codex', { taskId: 'task-a' })).toMatchObject({
type,
retryable,
provider: 'codex',
technicalMessage: message,
context: { taskId: 'task-a' },
});
});
it.each([
[{ message: 'invalid api key' }, 'invalid api key'],
[{ error: { message: 'payment required' } }, 'payment required'],
[{ error: 'token revoked' }, 'token revoked'],
[{ error: { code: 'unexpected' } }, '{"code":"unexpected"}'],
[{ code: 123 }, '{"code":123}'],
[null, 'null'],
[undefined, 'undefined'],
[42, '42'],
])('preserves structured evidence while classifying it', (input, technicalMessage) => {
expect(classifyError(input).technicalMessage).toBe(technicalMessage);
});
it('returns a safe client response with actionable authentication guidance', () => {
const evidence = 'invalid api key: test-only-sensitive-value';
const response = createErrorResponse(evidence, 'claude', { taskId: 'task-a' });
expect(response).toMatchObject({
success: false,
errorType: ErrorType.AUTHENTICATION,
severity: ErrorSeverity.HIGH,
retryable: false,
});
expect(response.suggestedAction).toContain('Verify your API key');
expect(JSON.stringify(response)).not.toContain('test-only-sensitive-value');
expect(getUserFriendlyErrorMessage(evidence, 'claude')).toMatch(/^\[CLAUDE\]/);
expect(getUserFriendlyErrorMessage(evidence)).not.toContain('[CLAUDE]');
});
it.each(['claude', 'codex', 'cursor'] as const)(
'keeps %s error shortcuts consistent with fatal authentication, billing, and retryable rate limits',
(provider) => {
const handler = ProviderErrorHandler[provider];
expect(handler.classify('unauthorized')).toMatchObject({
provider,
type: ErrorType.AUTHENTICATION,
retryable: false,
});
expect(handler.getUserMessage('quota exceeded')).toContain(`[${provider.toUpperCase()}]`);
expect(handler.isAuth('token revoked')).toBe(true);
expect(handler.isAuth('network')).toBe(false);
expect(handler.isBilling('payment required')).toBe(true);
expect(handler.isBilling('network')).toBe(false);
expect(handler.isRateLimit('429')).toBe(true);
expect(handler.isRateLimit('network')).toBe(false);
}
);
it('exposes common policy predicates and diagnostic context', () => {
expect(isAuthenticationError('unauthorized')).toBe(true);
expect(isBillingError('insufficient credit')).toBe(true);
expect(isRateLimitError('too many requests')).toBe(true);
expect(isRetryableError('forbidden')).toBe(false);
expect(isRetryableError('network')).toBe(true);
logError('forbidden', 'codex', 'dispatch', { taskId: 'task-a' });
expect(logger.error).toHaveBeenLastCalledWith(
'Error in codex during dispatch',
expect.objectContaining({
type: ErrorType.PERMISSION,
context: { operation: 'dispatch', taskId: 'task-a' },
})
);
logError('unfamiliar');
expect(logger.error.mock.calls.at(-1)?.[0]).toBe('Error in unknown');
});
});
describe('provider retries', () => {
it('retries temporary failures with bounded exponential delays and returns the first success', async () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0);
const operation = vi
.fn()
.mockRejectedValueOnce(new Error('network'))
.mockRejectedValueOnce(new Error('503'))
.mockResolvedValue('ready');
const result = createRetryHandler(3, 100)(operation);
expect(operation).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(99);
expect(operation).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(operation).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(199);
expect(operation).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
await expect(result).resolves.toBe('ready');
expect(operation).toHaveBeenCalledTimes(3);
});
it('does not retry invalid credentials or spend the retry budget on permanent failures', async () => {
vi.useFakeTimers();
const failure = new Error('unauthorized');
const operation = vi.fn().mockRejectedValue(failure);
await expect(createRetryHandler()(operation)).rejects.toBe(failure);
expect(operation).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it('stops after the configured budget and preserves the last error', async () => {
vi.useFakeTimers();
const failure = new Error('custom transient');
const operation = vi.fn().mockRejectedValue(failure);
const policy = vi.fn().mockReturnValue(true);
const result = createRetryHandler(2, 1)(operation, policy);
const assertion = expect(result).rejects.toBe(failure);
await vi.runAllTimersAsync();
await assertion;
expect(operation).toHaveBeenCalledTimes(3);
expect(policy).toHaveBeenCalledTimes(2);
});
});