From 70b74e1789f151661bff642c5d174ae0d7dba54b Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Mon, 11 May 2026 15:16:26 +0530 Subject: [PATCH 1/3] feat(retry): implement retry logic for ECONNRESET and EPIPE errors in requests --- src/lib/retry.ts | 46 ++++++++++--- tests/lib/runtime.test.ts | 141 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 9 deletions(-) diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 3540ff73e4..0e1ed24264 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -1,9 +1,28 @@ +import { TimeoutError } from "./errors.js"; + const MAX_REQUEST_RETRY_JITTER = 250; const MAX_REQUEST_RETRY_DELAY = 10000; const DEFAULT_NUMBER_RETRIES = 3; const MAX_NUMBER_RETRIES = 10; const BASE_DELAY = 500; +// Transient network errors that are safe to retry — failures that can self-heal +// without any config change (socket reset, broken pipe, aborted connection). +// Deliberately excludes ENOTFOUND, ECONNREFUSED, cert errors — those won't self-heal. +const RETRYABLE_ERROR_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]); + +function isRetryableNetworkError(e: unknown): boolean { + if (typeof e !== "object" || e === null) return false; + const code = (e as NodeJS.ErrnoException).code ?? ""; + return RETRYABLE_ERROR_CODES.has(code); +} + +function calculateWait(nrOfTries: number): number { + let wait = BASE_DELAY * Math.pow(2, nrOfTries - 1); + wait = getRandomInt(wait + 1, wait + MAX_REQUEST_RETRY_JITTER); + return Math.min(wait, MAX_REQUEST_RETRY_DELAY); +} + /** * @private * Function that returns a random int between a configurable min and max. @@ -43,7 +62,9 @@ export interface RetryConfiguration { */ maxRetries?: number; /** - * Status Codes on which the SDK should trigger retries. + * HTTP Status Codes on which the SDK should trigger retries. + * Note: transient network errors (ECONNRESET, EPIPE, ECONNABORTED) are always retried + * up to maxRetries regardless of this setting. Use `enabled: false` to disable all retries. * Defaults to [429]. */ retryWhen?: number[]; @@ -57,19 +78,26 @@ export function retry(action: () => Promise, { maxRetries, retryWhen } const nrOfTriesToAttempt = Math.min(MAX_NUMBER_RETRIES, maxRetries ?? DEFAULT_NUMBER_RETRIES); let nrOfTries = 0; - const retryAndWait = async () => { + const retryAndWait = async (): Promise => { let result: Response; - result = await action(); + try { + result = await action(); + } catch (e: unknown) { + if (e instanceof TimeoutError) { + throw e; + } + if (isRetryableNetworkError(e) && nrOfTries < nrOfTriesToAttempt) { + nrOfTries++; + await pause(calculateWait(nrOfTries)); + return retryAndWait(); + } + throw e; + } if ((retryWhen || [429]).includes(result.status) && nrOfTries < nrOfTriesToAttempt) { nrOfTries++; - - let wait = BASE_DELAY * Math.pow(2, nrOfTries - 1); - wait = getRandomInt(wait + 1, wait + MAX_REQUEST_RETRY_JITTER); - wait = Math.min(wait, MAX_REQUEST_RETRY_DELAY); - - await pause(wait); + await pause(calculateWait(nrOfTries)); result = await retryAndWait(); } diff --git a/tests/lib/runtime.test.ts b/tests/lib/runtime.test.ts index 29ad35aee7..fe3a31cc1e 100644 --- a/tests/lib/runtime.test.ts +++ b/tests/lib/runtime.test.ts @@ -268,6 +268,147 @@ describe("Runtime", () => { ).rejects.toThrowError(expect.objectContaining({ statusCode: 429 })); }); + it("should retry on ECONNRESET when retry is enabled", async () => { + const request = nock(URL, { encodedQueryParams: true }) + .get("/clients") + .times(2) + .replyWithError({ code: "ECONNRESET", message: "socket hang up" }) + .get("/clients") + .reply(200, [{ client_id: "123" }]); + + const client = new TestClient({ + baseUrl: URL, + parseError, + }); + + const response = await client.testRequest({ + path: `/clients`, + method: "GET", + }); + + const data = (await response.json()) as Array<{ client_id: string }>; + expect(data[0].client_id).toBe("123"); + expect(request.isDone()).toBe(true); + }); + + it("should retry on EPIPE when retry is enabled", async () => { + const request = nock(URL, { encodedQueryParams: true }) + .get("/clients") + .replyWithError({ code: "EPIPE", message: "write EPIPE" }) + .get("/clients") + .reply(200, [{ client_id: "123" }]); + + const client = new TestClient({ + baseUrl: URL, + parseError, + }); + + const response = await client.testRequest({ + path: `/clients`, + method: "GET", + }); + + const data = (await response.json()) as Array<{ client_id: string }>; + expect(data[0].client_id).toBe("123"); + expect(request.isDone()).toBe(true); + }); + + it("should throw after exhausting retries on repeated ECONNRESET", async () => { + nock(URL, { encodedQueryParams: true }) + .get("/clients") + .times(4) + .replyWithError({ code: "ECONNRESET", message: "socket hang up" }); + + const client = new TestClient({ + baseUrl: URL, + parseError, + }); + + await expect( + client.testRequest({ + path: `/clients`, + method: "GET", + }), + ).rejects.toThrowError( + expect.objectContaining({ cause: expect.objectContaining({ message: "socket hang up" }) }), + ); + }); + + it("should not retry ECONNRESET when retry is disabled", async () => { + nock(URL, { encodedQueryParams: true }) + .get("/clients") + .replyWithError({ code: "ECONNRESET", message: "socket hang up" }) + .get("/clients") + .reply(200, [{ client_id: "123" }]); + + const client = new TestClient({ + baseUrl: URL, + parseError, + retry: { enabled: false }, + }); + + await expect( + client.testRequest({ + path: `/clients`, + method: "GET", + }), + ).rejects.toThrowError( + expect.objectContaining({ cause: expect.objectContaining({ message: "socket hang up" }) }), + ); + }); + + it("should not retry non-retryable errors like ECONNREFUSED", async () => { + nock(URL, { encodedQueryParams: true }) + .get("/clients") + .replyWithError({ code: "ECONNREFUSED", message: "connect ECONNREFUSED" }) + .get("/clients") + .reply(200, [{ client_id: "123" }]); + + const client = new TestClient({ + baseUrl: URL, + parseError, + }); + + await expect( + client.testRequest({ + path: `/clients`, + method: "GET", + }), + ).rejects.toThrowError( + expect.objectContaining({ cause: expect.objectContaining({ message: "connect ECONNREFUSED" }) }), + ); + + // Second nock was never consumed — confirms no retry occurred + expect(nock.pendingMocks().length).toBe(1); + nock.cleanAll(); + }); + + it("should not retry on timeout errors", async () => { + nock(URL) + .get("/clients") + .delayConnection(100) + .reply(200, [{ client_id: "123" }]) + .get("/clients") + .reply(200, [{ client_id: "123" }]); + + const client = new TestClient({ + baseUrl: URL, + parseError, + timeoutDuration: 50, + }); + + await expect( + client.testRequest({ + path: `/clients`, + method: "GET", + }), + ).rejects.toThrowError(expect.objectContaining({ cause: expect.objectContaining({ name: "TimeoutError" }) })); + + // Second nock was never consumed — confirms no retry occurred + expect(nock.pendingMocks().length).toBe(1); + nock.abortPendingRequests(); + }); + it("should timeout after default time", async () => { nock(URL).get("/clients").delayConnection(10000).reply(200, []); From aef10ef078bf29b27f61add93a3a6e96d39e4038 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 2 Jun 2026 14:13:46 +0530 Subject: [PATCH 2/3] fix: update default maxRetries to 3 and add retry test for ECONNABORTED --- src/lib/retry.ts | 2 +- tests/lib/runtime.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 0e1ed24264..8427250877 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -58,7 +58,7 @@ export interface RetryConfiguration { enabled?: boolean; /** * Configure the max amount of retries the SDK should do. - * Defaults to 5. + * Defaults to 3. */ maxRetries?: number; /** diff --git a/tests/lib/runtime.test.ts b/tests/lib/runtime.test.ts index fe3a31cc1e..5587852639 100644 --- a/tests/lib/runtime.test.ts +++ b/tests/lib/runtime.test.ts @@ -313,6 +313,28 @@ describe("Runtime", () => { expect(request.isDone()).toBe(true); }); + it("should retry on ECONNABORTED when retry is enabled", async () => { + const request = nock(URL, { encodedQueryParams: true }) + .get("/clients") + .replyWithError({ code: "ECONNABORTED", message: "connection aborted" }) + .get("/clients") + .reply(200, [{ client_id: "123" }]); + + const client = new TestClient({ + baseUrl: URL, + parseError, + }); + + const response = await client.testRequest({ + path: `/clients`, + method: "GET", + }); + + const data = (await response.json()) as Array<{ client_id: string }>; + expect(data[0].client_id).toBe("123"); + expect(request.isDone()).toBe(true); + }); + it("should throw after exhausting retries on repeated ECONNRESET", async () => { nock(URL, { encodedQueryParams: true }) .get("/clients") From a3831e87074d0367416c78ad1b7f4f41e8c68965 Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Mon, 8 Jun 2026 13:49:35 +0530 Subject: [PATCH 3/3] fix(retry): check e.cause?.code for native fetch error shape --- src/lib/retry.ts | 40 ++++++++++++++++++++++----------------- tests/lib/runtime.test.ts | 32 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 8427250877..21507b2627 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -6,23 +6,6 @@ const DEFAULT_NUMBER_RETRIES = 3; const MAX_NUMBER_RETRIES = 10; const BASE_DELAY = 500; -// Transient network errors that are safe to retry — failures that can self-heal -// without any config change (socket reset, broken pipe, aborted connection). -// Deliberately excludes ENOTFOUND, ECONNREFUSED, cert errors — those won't self-heal. -const RETRYABLE_ERROR_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]); - -function isRetryableNetworkError(e: unknown): boolean { - if (typeof e !== "object" || e === null) return false; - const code = (e as NodeJS.ErrnoException).code ?? ""; - return RETRYABLE_ERROR_CODES.has(code); -} - -function calculateWait(nrOfTries: number): number { - let wait = BASE_DELAY * Math.pow(2, nrOfTries - 1); - wait = getRandomInt(wait + 1, wait + MAX_REQUEST_RETRY_JITTER); - return Math.min(wait, MAX_REQUEST_RETRY_DELAY); -} - /** * @private * Function that returns a random int between a configurable min and max. @@ -46,6 +29,26 @@ async function pause(delay: number) { return new Promise((resolve) => setTimeout(resolve, delay)); } +// Transient network errors that are safe to retry — failures that can self-heal +// without any config change (socket reset, broken pipe, aborted connection). +// Deliberately excludes ENOTFOUND, ECONNREFUSED, cert errors — those won't self-heal. +const RETRYABLE_ERROR_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]); + +function isRetryableNetworkError(e: unknown): boolean { + if (typeof e !== "object" || e === null) return false; + // Check both e.code (old request-lib / nock shape) and e.cause.code + // (native fetch / undici shape: TypeError: fetch failed { cause: { code } }) + const err = e as NodeJS.ErrnoException & { cause?: NodeJS.ErrnoException }; + const code = err.code ?? err.cause?.code ?? ""; + return RETRYABLE_ERROR_CODES.has(code); +} + +function calculateWait(nrOfTries: number): number { + let wait = BASE_DELAY * Math.pow(2, nrOfTries - 1); + wait = getRandomInt(wait + 1, wait + MAX_REQUEST_RETRY_JITTER); + return Math.min(wait, MAX_REQUEST_RETRY_DELAY); +} + /** * Configure the retry logic for http calls. * By default, this retries any request that returns a 429 3 times. @@ -59,6 +62,9 @@ export interface RetryConfiguration { /** * Configure the max amount of retries the SDK should do. * Defaults to 3. + * Note: this budget is shared between HTTP status code retries (e.g. 429) and + * transient network error retries (e.g. ECONNRESET). For example, with maxRetries: 3, + * 2 network retries and 1 status code retry would exhaust the full budget. */ maxRetries?: number; /** diff --git a/tests/lib/runtime.test.ts b/tests/lib/runtime.test.ts index 5587852639..f6a9685856 100644 --- a/tests/lib/runtime.test.ts +++ b/tests/lib/runtime.test.ts @@ -291,6 +291,38 @@ describe("Runtime", () => { expect(request.isDone()).toBe(true); }); + it("should retry on ECONNRESET wrapped in TypeError (native fetch shape)", async () => { + // Native fetch (undici) does not put code on the top-level error. + // It throws: TypeError: fetch failed { cause: Error: read ECONNRESET { code: "ECONNRESET" } } + // This test ensures isRetryableNetworkError handles that shape. + let callCount = 0; + const mockFetch = async (): Promise => { + callCount++; + if (callCount <= 2) { + const cause = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }); + const err = new TypeError("fetch failed"); + (err as any).cause = cause; + throw err; + } + return new Response(JSON.stringify([{ client_id: "123" }]), { status: 200 }); + }; + + const client = new TestClient({ + baseUrl: URL, + parseError, + fetch: mockFetch, + }); + + const response = await client.testRequest({ + path: `/clients`, + method: "GET", + }); + + const data = (await response.json()) as Array<{ client_id: string }>; + expect(data[0].client_id).toBe("123"); + expect(callCount).toBe(3); + }); + it("should retry on EPIPE when retry is enabled", async () => { const request = nock(URL, { encodedQueryParams: true }) .get("/clients")