189 lines
6.7 KiB
TypeScript
189 lines
6.7 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { isRetryable, withRetry, DEFAULT_RETRY_CONFIG } from './retry.js';
|
|
import type { RetryConfig } from './retry.js';
|
|
|
|
describe('isRetryable', () => {
|
|
it('returns true for generic errors', () => {
|
|
const error = new Error('Connection reset by peer');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(true);
|
|
});
|
|
|
|
it('returns true for timeout errors (transient)', () => {
|
|
const error = new Error('Request timed out after 20000ms');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(true);
|
|
});
|
|
|
|
it('returns false for authentication errors', () => {
|
|
const error = new Error('Invalid API key: authentication failed');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('returns false for invalid_api_key errors', () => {
|
|
const error = new Error('Error: invalid_api_key');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('returns false for unauthorized errors', () => {
|
|
const error = new Error('Request unauthorized');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('returns false for invalid_request errors', () => {
|
|
const error = new Error('invalid_request: missing parameter');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('returns false for context_length_exceeded errors', () => {
|
|
const error = new Error('context_length_exceeded: max 128k tokens');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('returns false for content_policy errors', () => {
|
|
const error = new Error('content_policy violation detected');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('returns false for "does not support" errors', () => {
|
|
const error = new Error('registry.ollama.ai/library/qwen2.5:latest does not support tools');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('is case-insensitive when matching patterns', () => {
|
|
const error = new Error('AUTHENTICATION error');
|
|
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
|
|
});
|
|
|
|
it('uses custom patterns when provided', () => {
|
|
const error = new Error('quota exceeded');
|
|
expect(isRetryable(error, ['quota'])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('withRetry', () => {
|
|
// Use minimal real delays to avoid fake-timer race conditions
|
|
const fastConfig: RetryConfig = {
|
|
maxRetries: 3,
|
|
initialDelayMs: 1,
|
|
backoffMultiplier: 1,
|
|
maxDelayMs: 5,
|
|
nonRetryablePatterns: DEFAULT_RETRY_CONFIG.nonRetryablePatterns,
|
|
};
|
|
|
|
it('succeeds on first attempt without delay', async () => {
|
|
const fn = vi.fn().mockResolvedValue('success');
|
|
|
|
const result = await withRetry(fn, fastConfig);
|
|
|
|
expect(result).toBe('success');
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retries on transient failure then succeeds', async () => {
|
|
const fn = vi.fn()
|
|
.mockRejectedValueOnce(new Error('temporary network issue'))
|
|
.mockRejectedValueOnce(new Error('temporary network issue'))
|
|
.mockResolvedValueOnce('recovered');
|
|
|
|
const result = await withRetry(fn, fastConfig, 'test-op');
|
|
|
|
expect(result).toBe('recovered');
|
|
expect(fn).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('throws after maxRetries exhausted', async () => {
|
|
const fn = vi.fn().mockRejectedValue(new Error('persistent failure'));
|
|
|
|
await expect(withRetry(fn, fastConfig, 'test-op')).rejects.toThrow('persistent failure');
|
|
// 1 initial + 3 retries = 4 total
|
|
expect(fn).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('does not retry non-retryable errors', async () => {
|
|
const fn = vi.fn().mockRejectedValue(new Error('invalid_api_key'));
|
|
|
|
await expect(withRetry(fn, fastConfig)).rejects.toThrow('invalid_api_key');
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not retry authentication errors', async () => {
|
|
const fn = vi.fn().mockRejectedValue(new Error('Request unauthorized'));
|
|
|
|
await expect(withRetry(fn, fastConfig)).rejects.toThrow('Request unauthorized');
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not retry "does not support tools" errors', async () => {
|
|
const fn = vi.fn().mockRejectedValue(
|
|
new Error('registry.ollama.ai/library/qwen2.5:latest does not support tools'),
|
|
);
|
|
|
|
await expect(withRetry(fn, fastConfig)).rejects.toThrow('does not support tools');
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('converts non-Error throws to Error objects', async () => {
|
|
const fn = vi.fn().mockRejectedValue('string error');
|
|
|
|
await expect(withRetry(fn, { ...fastConfig, maxRetries: 0 })).rejects.toThrow('string error');
|
|
});
|
|
|
|
it('respects maxDelayMs cap', async () => {
|
|
const cappedConfig: RetryConfig = {
|
|
maxRetries: 2,
|
|
initialDelayMs: 1,
|
|
backoffMultiplier: 10,
|
|
maxDelayMs: 2,
|
|
nonRetryablePatterns: [],
|
|
};
|
|
|
|
let callCount = 0;
|
|
const fn = vi.fn().mockImplementation(() => {
|
|
callCount++;
|
|
if (callCount < 3) {return Promise.reject(new Error('fail'));}
|
|
return Promise.resolve('ok');
|
|
});
|
|
|
|
// If maxDelayMs weren't respected, a 10x multiplier could cause very long waits.
|
|
// With maxDelayMs=2ms, this completes quickly.
|
|
const result = await withRetry(fn, cappedConfig, 'capped-test');
|
|
expect(result).toBe('ok');
|
|
expect(fn).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('uses default config when none provided', async () => {
|
|
const fn = vi.fn().mockResolvedValue('default-config');
|
|
|
|
const result = await withRetry(fn);
|
|
|
|
expect(result).toBe('default-config');
|
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('increases delay exponentially between retries', async () => {
|
|
const timestamps: number[] = [];
|
|
const config: RetryConfig = {
|
|
maxRetries: 2,
|
|
initialDelayMs: 20,
|
|
backoffMultiplier: 2,
|
|
maxDelayMs: 1000,
|
|
nonRetryablePatterns: [],
|
|
};
|
|
|
|
const fn = vi.fn().mockImplementation(() => {
|
|
timestamps.push(Date.now());
|
|
if (timestamps.length < 3) {return Promise.reject(new Error('fail'));}
|
|
return Promise.resolve('ok');
|
|
});
|
|
|
|
await withRetry(fn, config, 'backoff-test');
|
|
|
|
expect(fn).toHaveBeenCalledTimes(3);
|
|
// First retry delay: ~20ms (jitter 50-100% of 20 = 10-20ms)
|
|
// Second retry delay: ~40ms (jitter 50-100% of 40 = 20-40ms)
|
|
const firstDelay = timestamps[1] - timestamps[0];
|
|
const secondDelay = timestamps[2] - timestamps[1];
|
|
// Second delay should be roughly double the first (within jitter range)
|
|
expect(secondDelay).toBeGreaterThanOrEqual(firstDelay * 0.7);
|
|
});
|
|
});
|