From 65918bf62e6afc2b1f29aa4fccd121f2c3bbc1da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 16:17:44 +0000 Subject: [PATCH 1/2] Retry transient TLS network failures Co-authored-by: Neil Raina --- src/app/api/core/utils/withRetry.test.ts | 49 ++++++++++++++++++++++ src/app/api/core/utils/withRetry.ts | 53 ++++++++++++++++++++---- src/utils/CopilotAPI.ts | 14 ++++--- 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 src/app/api/core/utils/withRetry.test.ts diff --git a/src/app/api/core/utils/withRetry.test.ts b/src/app/api/core/utils/withRetry.test.ts new file mode 100644 index 000000000..06730eeda --- /dev/null +++ b/src/app/api/core/utils/withRetry.test.ts @@ -0,0 +1,49 @@ +import { isTransientNetworkError, withRetry } from './withRetry' + +jest.mock('@sentry/nextjs', () => ({ + withScope: jest.fn((callback: (scope: { addEventProcessor: jest.Mock }) => void) => { + callback({ addEventProcessor: jest.fn() }) + }), +})) + +const createTlsDisconnectError = () => + Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('Client network socket disconnected before secure TLS connection was established'), { + code: 'ECONNRESET', + }), + }) + +describe('isTransientNetworkError', () => { + it('detects TLS disconnect errors nested under fetch failures', () => { + expect(isTransientNetworkError(createTlsDisconnectError())).toBe(true) + }) + + it('does not classify unrelated errors as transient network errors', () => { + expect(isTransientNetworkError(new Error('validation failed'))).toBe(false) + }) +}) + +describe('withRetry', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('retries transient network errors that do not have an HTTP status', async () => { + const fn = jest.fn().mockRejectedValueOnce(createTlsDisconnectError()).mockResolvedValueOnce('ok') + + await expect(withRetry(fn, [])).resolves.toBe('ok') + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('does not retry errors without retryable status or transient network causes', async () => { + const error = new Error('validation failed') + const fn = jest.fn().mockRejectedValue(error) + + await expect(withRetry(fn, [])).rejects.toBe(error) + expect(fn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/app/api/core/utils/withRetry.ts b/src/app/api/core/utils/withRetry.ts index dbc9a2d4f..0f2993da3 100644 --- a/src/app/api/core/utils/withRetry.ts +++ b/src/app/api/core/utils/withRetry.ts @@ -4,6 +4,48 @@ import * as Sentry from '@sentry/nextjs' export const RETRY_404_ENABLED = process.env.RETRY_404 === 'true' +const TRANSIENT_NETWORK_ERROR_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']) +const TLS_DISCONNECT_MESSAGE = 'Client network socket disconnected before secure TLS connection was established' + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return '' +} + +const getErrorCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object') return undefined + + const code = (error as { code?: unknown }).code + return typeof code === 'string' ? code : undefined +} + +const getErrorCause = (error: unknown): unknown => { + if (!error || typeof error !== 'object') return undefined + + return (error as { cause?: unknown }).cause +} + +const isRetryableStatusError = (error: unknown): boolean => { + const status = (error as Partial)?.status + if (typeof status !== 'number') return false + + return [408, 429].includes(status) || (status >= 500 && status <= 511) || (RETRY_404_ENABLED && status === 404) +} + +export const isTransientNetworkError = (error: unknown): boolean => { + const code = getErrorCode(error) + if (code && TRANSIENT_NETWORK_ERROR_CODES.has(code)) return true + + const message = getErrorMessage(error) + if (message.includes(TLS_DISCONNECT_MESSAGE)) return true + + const cause = getErrorCause(error) + if (!cause) return false + + return isTransientNetworkError(cause) +} + export const withRetry = async (fn: (...args: any[]) => Promise, args: any[]): Promise => { let isEventProcessorRegistered = false @@ -39,15 +81,8 @@ export const withRetry = async (fn: (...args: any[]) => Promise, args: any `CopilotAPI#withRetry | Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left`, ) }, - shouldRetry: (error: any) => { - // Typecasting because Copilot doesn't export an error class - const err = error as StatusableError - // Retry if statusCode is 429 (ratelimit), 408 (timeouts), or any server related (5xx) error - return ( - [408, 429].includes(err.status) || - (err.status >= 500 && err.status <= 511) || - (RETRY_404_ENABLED && err.status === 404) - ) + shouldRetry: (error: unknown) => { + return isRetryableStatusError(error) || isTransientNetworkError(error) }, }, ) diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index 20b721548..42baed4c2 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -370,11 +370,15 @@ export class CopilotAPI { console.info('CopilotAPI#dispatchWebhook | Request headers:', headers) try { - await fetch(url, { - method: 'POST', - headers, - body: payload ? JSON.stringify(payload) : null, - }) + await withRetry( + () => + fetch(url, { + method: 'POST', + headers, + body: payload ? JSON.stringify(payload) : null, + }), + [], + ) } catch (e) { console.error(`Failed to dispatch webhook for event ${eventName}`, e) } From 726036adacec5d9ff2a52c0f3a2daed17fe9157a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 16:18:32 +0000 Subject: [PATCH 2/2] Mock retry dependency in network retry tests Co-authored-by: Neil Raina --- src/app/api/core/utils/withRetry.test.ts | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/api/core/utils/withRetry.test.ts b/src/app/api/core/utils/withRetry.test.ts index 06730eeda..2c4353104 100644 --- a/src/app/api/core/utils/withRetry.test.ts +++ b/src/app/api/core/utils/withRetry.test.ts @@ -6,6 +6,41 @@ jest.mock('@sentry/nextjs', () => ({ }), })) +jest.mock('p-retry', () => ({ + __esModule: true, + default: jest.fn( + async ( + fn: () => Promise, + options: { + retries?: number + onFailedAttempt?: (error: Error & { attemptNumber: number; retriesLeft: number }) => void + shouldRetry?: (error: unknown) => boolean + }, + ) => { + const maxAttempts = (options.retries ?? 0) + 1 + + const runAttempt = async (attemptNumber: number): Promise => { + try { + return await fn() + } catch (error) { + const retriesLeft = maxAttempts - attemptNumber + const failedAttemptError = Object.assign(error instanceof Error ? error : new Error(String(error)), { + attemptNumber, + retriesLeft, + }) + + options.onFailedAttempt?.(failedAttemptError) + + if (retriesLeft <= 0 || !options.shouldRetry?.(error)) throw error + return runAttempt(attemptNumber + 1) + } + } + + return runAttempt(1) + }, + ), +})) + const createTlsDisconnectError = () => Object.assign(new TypeError('fetch failed'), { cause: Object.assign(new Error('Client network socket disconnected before secure TLS connection was established'), {