From c1317d55085560b0fe05f3edd609fcf603208858 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Mon, 13 Apr 2026 14:57:57 +0530 Subject: [PATCH 1/3] ix: implement retry logic for socket hang up errors in HTTP requests --- src/lib/retry.ts | 19 +++++++++-- tests/lib/runtime.test.ts | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 3540ff73e4..96afb51bbc 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -57,10 +57,25 @@ 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: any) { + if (e.name !== "TimeoutError" && 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); + + return retryAndWait(); + } + throw e; + } if ((retryWhen || [429]).includes(result.status) && nrOfTries < nrOfTriesToAttempt) { nrOfTries++; diff --git a/tests/lib/runtime.test.ts b/tests/lib/runtime.test.ts index 29ad35aee7..40ac20b19b 100644 --- a/tests/lib/runtime.test.ts +++ b/tests/lib/runtime.test.ts @@ -268,6 +268,73 @@ describe("Runtime", () => { ).rejects.toThrowError(expect.objectContaining({ statusCode: 429 })); }); + it("should retry on socket hang up (ECONNRESET) when retry is enabled", async () => { + // issue #1020 — network errors were not retried, only HTTP status codes were + 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 throw after exhausting retries on repeated socket hang up", async () => { + // issue #1020 — should give up after maxRetries attempts + 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.toThrow(); + }); + + it("should not retry on socket hang up when retry is disabled", async () => { + // issue #1020 — disabled retry should still not retry network errors + 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.toThrow(); + }); + it("should timeout after default time", async () => { nock(URL).get("/clients").delayConnection(10000).reply(200, []); From 813295bbb5da8deae7f25bf2c51f1ce932550cbe Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Mon, 13 Apr 2026 15:14:42 +0530 Subject: [PATCH 2/3] ix: resolve lint warning - use unknown instead of any in catch block --- src/lib/retry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 96afb51bbc..fa2f01a2f0 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -62,8 +62,8 @@ export function retry(action: () => Promise, { maxRetries, retryWhen } try { result = await action(); - } catch (e: any) { - if (e.name !== "TimeoutError" && nrOfTries < nrOfTriesToAttempt) { + } catch (e: unknown) { + if (!(e instanceof Error && e.name === "TimeoutError") && nrOfTries < nrOfTriesToAttempt) { nrOfTries++; let wait = BASE_DELAY * Math.pow(2, nrOfTries - 1); From aee935408542681684d186d045b550b90f9c3b33 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Fri, 24 Apr 2026 10:32:53 +0530 Subject: [PATCH 3/3] fix: enhance retry logic to handle socket hang up and timeout errors --- src/lib/retry.ts | 30 ++++++++++++++---------------- tests/lib/runtime.test.ts | 31 +++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/lib/retry.ts b/src/lib/retry.ts index fa2f01a2f0..7af5d2f911 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -1,3 +1,5 @@ +import { TimeoutError } from "./errors.js"; + const MAX_REQUEST_RETRY_JITTER = 250; const MAX_REQUEST_RETRY_DELAY = 10000; const DEFAULT_NUMBER_RETRIES = 3; @@ -27,6 +29,12 @@ async function pause(delay: number) { return new Promise((resolve) => setTimeout(resolve, delay)); } +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. @@ -43,7 +51,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: network-level errors (e.g. ECONNRESET) are always retried up to maxRetries, + * regardless of this setting. Use `enabled: false` to disable all retries. * Defaults to [429]. */ retryWhen?: number[]; @@ -63,15 +73,9 @@ export function retry(action: () => Promise, { maxRetries, retryWhen } try { result = await action(); } catch (e: unknown) { - if (!(e instanceof Error && e.name === "TimeoutError") && nrOfTries < nrOfTriesToAttempt) { + if (!(e instanceof TimeoutError) && 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)); return retryAndWait(); } throw e; @@ -79,13 +83,7 @@ export function retry(action: () => Promise, { maxRetries, retryWhen } 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 40ac20b19b..3437f72ac3 100644 --- a/tests/lib/runtime.test.ts +++ b/tests/lib/runtime.test.ts @@ -310,11 +310,12 @@ describe("Runtime", () => { path: `/clients`, method: "GET", }), - ).rejects.toThrow(); + ).rejects.toThrowError( + expect.objectContaining({ cause: expect.objectContaining({ message: "socket hang up" }) }), + ); }); it("should not retry on socket hang up when retry is disabled", async () => { - // issue #1020 — disabled retry should still not retry network errors nock(URL, { encodedQueryParams: true }) .get("/clients") .replyWithError({ code: "ECONNRESET", message: "socket hang up" }) @@ -335,6 +336,32 @@ describe("Runtime", () => { ).rejects.toThrow(); }); + 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, []);