diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 2af05fb148..fe607e6914 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -307,11 +307,50 @@ describe('retryWithBackoff', () => { }); describe('Fetch error retries', () => { - const fetchErrorMsg = 'exception TypeError: fetch failed sending request'; - it('should retry on specific fetch error when retryFetchErrors is true', async () => { const mockFn = vi.fn(); - mockFn.mockRejectedValueOnce(new Error(fetchErrorMsg)); + mockFn.mockRejectedValueOnce(new TypeError('fetch failed')); + mockFn.mockResolvedValueOnce('success'); + + const promise = retryWithBackoff(mockFn, { + retryFetchErrors: true, + initialDelayMs: 10, + }); + + await vi.runAllTimersAsync(); + + const result = await promise; + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should retry on common network error codes (ECONNRESET)', async () => { + const mockFn = vi.fn(); + const error = new Error('read ECONNRESET'); + (error as any).code = 'ECONNRESET'; + mockFn.mockRejectedValueOnce(error); + mockFn.mockResolvedValueOnce('success'); + + const promise = retryWithBackoff(mockFn, { + retryFetchErrors: true, + initialDelayMs: 10, + }); + + await vi.runAllTimersAsync(); + + const result = await promise; + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should retry on common network error codes in cause (ETIMEDOUT)', async () => { + const mockFn = vi.fn(); + const cause = new Error('Connect Timeout'); + (cause as any).code = 'ETIMEDOUT'; + const error = new Error('fetch failed'); + (error as any).cause = cause; + + mockFn.mockRejectedValueOnce(error); mockFn.mockResolvedValueOnce('success'); const promise = retryWithBackoff(mockFn, { @@ -329,13 +368,13 @@ describe('retryWithBackoff', () => { it.each([false, undefined])( 'should not retry on specific fetch error when retryFetchErrors is %s', async (retryFetchErrors) => { - const mockFn = vi.fn().mockRejectedValue(new Error(fetchErrorMsg)); + const mockFn = vi.fn().mockRejectedValue(new TypeError('fetch failed')); const promise = retryWithBackoff(mockFn, { retryFetchErrors, }); - await expect(promise).rejects.toThrow(fetchErrorMsg); + await expect(promise).rejects.toThrow('fetch failed'); expect(mockFn).toHaveBeenCalledTimes(1); }, ); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index d196a00283..99ed75ea7e 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -16,9 +16,6 @@ import { delay, createAbortError } from './delay.js'; import { debugLogger } from './debugLogger.js'; import { getErrorStatus, ModelNotFoundError } from './httpErrors.js'; -const FETCH_FAILED_MESSAGE = - 'exception TypeError: fetch failed sending request'; - export interface RetryOptions { maxAttempts: number; initialDelayMs: number; @@ -41,6 +38,40 @@ const DEFAULT_RETRY_OPTIONS: RetryOptions = { shouldRetryOnError: defaultShouldRetry, }; +const RETRYABLE_NETWORK_CODES = [ + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNREFUSED', +]; + +function getNetworkErrorCode(error: unknown): string | undefined { + const getCode = (obj: unknown): string | undefined => { + if (typeof obj !== 'object' || obj === null) { + return undefined; + } + if ('code' in obj && typeof (obj as { code: unknown }).code === 'string') { + return (obj as { code: string }).code; + } + return undefined; + }; + + const directCode = getCode(error); + if (directCode) { + return directCode; + } + + if (typeof error === 'object' && error !== null && 'cause' in error) { + return getCode((error as { cause: unknown }).cause); + } + + return undefined; +} + +const FETCH_FAILED_MESSAGE = 'fetch failed'; + /** * Default predicate function to determine if a retry should be attempted. * Retries on 429 (Too Many Requests) and 5xx server errors. @@ -52,12 +83,17 @@ function defaultShouldRetry( error: Error | unknown, retryFetchErrors?: boolean, ): boolean { - if ( - retryFetchErrors && - error instanceof Error && - error.message.includes(FETCH_FAILED_MESSAGE) - ) { - return true; + if (retryFetchErrors && error instanceof Error) { + // Check for generic fetch failed message (case-insensitive) + if (error.message.toLowerCase().includes(FETCH_FAILED_MESSAGE)) { + return true; + } + + // Check for common network error codes + const errorCode = getNetworkErrorCode(error); + if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) { + return true; + } } // Priority check for ApiError