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); }); });