From b235229bb7836c3ba733e0949512fe96146e18fd Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 28 Jul 2026 13:12:52 +0530 Subject: [PATCH 1/8] fix: classify request timeouts distinctly instead of unknown error retryResponseErrorHandler threw a plain object literal (error_message/error_code fields, no .message) for axios ECONNABORTED (client-side timeout) errors. APIError.fromAxiosError only recognizes .response.data, .request, or .message, so the plain object matched none of those and collapsed to a generic UNKNOWN_ERROR/status:0 - indistinguishable from a true network-layer failure, with no indication the request had actually timed out. Throw a real Error with .message and .code set instead, so it flows through the existing err.message branch in fromAxiosError and surfaces as a distinct, diagnosable TIMEOUT (408) error. DX-9991 --- src/lib/retryPolicy/delivery-sdk-handlers.ts | 9 ++--- .../retryPolicy/delivery-sdk-handlers.spec.ts | 33 ++++++++++++++----- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/lib/retryPolicy/delivery-sdk-handlers.ts b/src/lib/retryPolicy/delivery-sdk-handlers.ts index ba36848..b73da5b 100644 --- a/src/lib/retryPolicy/delivery-sdk-handlers.ts +++ b/src/lib/retryPolicy/delivery-sdk-handlers.ts @@ -59,12 +59,9 @@ export const retryResponseErrorHandler = (error: any, config: any, axiosInstance } if (error.code === 'ECONNABORTED') { - const customError = { - error_message: ERROR_MESSAGES.RETRY.TIMEOUT_EXCEEDED(config.timeout), - error_code: ERROR_MESSAGES.ERROR_CODES.TIMEOUT, - errors: null, - }; - throw customError; // Throw customError object + const timeoutError = new Error(ERROR_MESSAGES.RETRY.TIMEOUT_EXCEEDED(config.timeout)); + (timeoutError as any).code = ERROR_MESSAGES.ERROR_CODES.TIMEOUT; + throw timeoutError; } throw error; diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index f349656..90109d9 100644 --- a/test/retryPolicy/delivery-sdk-handlers.spec.ts +++ b/test/retryPolicy/delivery-sdk-handlers.spec.ts @@ -8,6 +8,7 @@ import { getRetryDelay, } from '../../src/lib/retryPolicy/delivery-sdk-handlers'; import MockAdapter from 'axios-mock-adapter'; +import { APIError } from '../../src/lib/api-error'; describe('retryRequestHandler', () => { it('should add retryCount to the request config', () => { @@ -201,22 +202,38 @@ describe('retryResponseErrorHandler', () => { jest.useRealTimers(); }); - it('should resolve the promise to 408 error if retryOnError is true and error code is ECONNABORTED', async () => { + it('should throw a real Error with the timeout duration and a TIMEOUT code when ECONNABORTED occurs', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, code: 'ECONNABORTED' }; const config = { retryLimit: 5, timeout: 1000 }; const client = axios.create(); try { await retryResponseErrorHandler(error, config, client); fail('Expected retryResponseErrorHandler to throw an error'); - } catch (err) { - expect(err).toEqual( - expect.objectContaining({ - error_code: 408, - error_message: `Request timeout of ${config.timeout}ms exceeded. Please try again or increase the timeout value in your configuration.`, - errors: null, - }) + } catch (err: any) { + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe( + `Request timeout of ${config.timeout}ms exceeded. Please try again or increase the timeout value in your configuration.` ); + expect(err.code).toBe(408); + } + }); + it('should classify a request timeout distinctly instead of as an unknown error', async () => { + const error = { config: { retryOnError: true, retryCount: 1 }, code: 'ECONNABORTED' }; + const config = { retryLimit: 5, timeout: 1000 }; + const client = axios.create(); + + let thrown: any; + try { + await retryResponseErrorHandler(error, config, client); + fail('Expected retryResponseErrorHandler to throw an error'); + } catch (err) { + thrown = err; } + + const apiError = APIError.fromAxiosError(thrown); + + expect(apiError.error_code).toBe(408); + expect(apiError.error_message).toContain('timeout'); }); it('should reject the promise if response status is 429 and retryCount exceeds retryLimit', async () => { const error = { From d911f4c5aa2c489cd777ca60926b4bc0dac01458 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 28 Jul 2026 13:13:46 +0530 Subject: [PATCH 2/8] refactor: drop no-throw-literal eslint-disable No longer needed now that the ECONNABORTED branch throws a real Error instead of a plain object literal. DX-9991 --- src/lib/retryPolicy/delivery-sdk-handlers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/retryPolicy/delivery-sdk-handlers.ts b/src/lib/retryPolicy/delivery-sdk-handlers.ts index b73da5b..fcaeab3 100644 --- a/src/lib/retryPolicy/delivery-sdk-handlers.ts +++ b/src/lib/retryPolicy/delivery-sdk-handlers.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-throw-literal */ import axios, { InternalAxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; import { ERROR_MESSAGES } from '../error-messages'; From ba7bf3d8c18769d7428f1c3a2cfe3a8726c683fb Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 28 Jul 2026 14:22:22 +0530 Subject: [PATCH 3/8] fix: retry transient network errors by default The default retryCondition only ever matched HTTP 429, so any network-level failure (DNS resolution failure, connection reset, client-side timeout) with no HTTP response got zero retries out of the box, even though the retry mechanism to handle them already existed and was already tested (retrying via a custom retryCondition already worked). Add a default retryCondition fallback covering ECONNABORTED, ETIMEDOUT, ECONNRESET, EPIPE, and EAI_AGAIN (DNS transient failure) when there is no response - deliberately excluding ENOTFOUND and ECONNREFUSED, which usually indicate a persistent misconfiguration rather than a transient blip. Placed in delivery-sdk-handlers.ts's own defaultConfig rather than contentstack-core.ts's httpClient defaults, since retryResponseErrorHandler is invoked with the raw StackConfig (see contentstack-typescript's stack/contentstack.ts), not client.defaults - a retryCondition set only on the axios instance defaults never reaches the actual retry decision. Verified against the real call pattern, not just isolated unit tests. DX-9991 --- src/lib/retryPolicy/delivery-sdk-handlers.ts | 3 ++ .../retryPolicy/delivery-sdk-handlers.spec.ts | 50 ++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/lib/retryPolicy/delivery-sdk-handlers.ts b/src/lib/retryPolicy/delivery-sdk-handlers.ts index ba36848..2922f1e 100644 --- a/src/lib/retryPolicy/delivery-sdk-handlers.ts +++ b/src/lib/retryPolicy/delivery-sdk-handlers.ts @@ -9,10 +9,13 @@ declare module 'axios' { } } +const TRANSIENT_NETWORK_ERROR_CODES = ['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'EPIPE', 'EAI_AGAIN']; + const defaultConfig = { maxRequests: 5, retryLimit: 5, retryDelay: 300, + retryCondition: (error: any) => !error.response && TRANSIENT_NETWORK_ERROR_CODES.includes(error.code), }; const DEFAULT_RETRY_DELAY_MS = 300; diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index f349656..75e3be6 100644 --- a/test/retryPolicy/delivery-sdk-handlers.spec.ts +++ b/test/retryPolicy/delivery-sdk-handlers.spec.ts @@ -120,6 +120,52 @@ describe('retryResponseErrorHandler', () => { jest.useRealTimers(); }); + it.each(['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'EPIPE', 'EAI_AGAIN'])( + 'should retry transient network error %s by default when no custom retryCondition is configured', + async (code) => { + const error = { + config: { retryOnError: true, retryCount: 1, method: 'get', url: '/default-retry' }, + code, + message: `simulated ${code}`, + }; + // No retryCondition here - mirrors the raw StackConfig a real stack() caller passes. + const config = { retryLimit: 3, retryDelay: 50 }; + const client = axios.create(); + + mock.onGet('/default-retry').reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + jest.advanceTimersByTime(50); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + + jest.useRealTimers(); + } + ); + + it.each(['ENOTFOUND', 'ECONNREFUSED'])( + 'should not retry non-transient network error %s by default when no custom retryCondition is configured', + async (code) => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + code, + message: `simulated ${code}`, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + try { + await retryResponseErrorHandler(error, config, client); + fail(`Expected retryResponseErrorHandler to throw for ${code}`); + } catch (err) { + expect(err).toEqual(error); + } + } + ); + it('should rethrow network errors when retryCondition returns false', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, @@ -203,7 +249,9 @@ describe('retryResponseErrorHandler', () => { it('should resolve the promise to 408 error if retryOnError is true and error code is ECONNABORTED', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, code: 'ECONNABORTED' }; - const config = { retryLimit: 5, timeout: 1000 }; + // retryCondition explicitly disabled here to isolate the non-retried ECONNABORTED throw path, + // since ECONNABORTED is retried by default now (see "should retry transient network error" tests). + const config = { retryLimit: 5, timeout: 1000, retryCondition: () => false }; const client = axios.create(); try { await retryResponseErrorHandler(error, config, client); From 667e212f6c8f5446fb0e23ab322d8595392bedc6 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 29 Jul 2026 14:25:16 +0530 Subject: [PATCH 4/8] fix: default to keep-alive connection agents in node environments httpAgent/httpsAgent defaulted to false, so every request opened a fresh TCP/TLS connection with zero reuse - relevant under Next.js prerendering concurrency, where redundant connection setup adds latency that pushes more requests toward the request timeout. Default to a keepAlive http.Agent/https.Agent instead, guarded behind a Node-only runtime check (typeof window === 'undefined', matching the existing isBrowser() convention in contentstack-typescript) since @contentstack/core has no browser field to redirect Node-only imports away from browser bundles the way axios does for itself. A fresh agent is created per httpClient() call rather than a shared module-level singleton - a deliberate tradeoff to avoid shared state across separate stack() instances in the same process. No behavior change for callers who already set their own httpAgent/ httpsAgent (including explicit false), and no behavior change at all in browser environments. DX-9991 --- src/lib/contentstack-core.ts | 6 ++++-- test/contentstack-core.node-agent.spec.ts | 22 ++++++++++++++++++++++ test/contentstack-core.spec.ts | 23 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 test/contentstack-core.node-agent.spec.ts diff --git a/src/lib/contentstack-core.ts b/src/lib/contentstack-core.ts index 69f7b3c..1c469ef 100644 --- a/src/lib/contentstack-core.ts +++ b/src/lib/contentstack-core.ts @@ -4,6 +4,8 @@ import axios, { AxiosRequestHeaders, getAdapter } from 'axios'; import { AxiosInstance, HttpClientParams } from './types'; import { ERROR_MESSAGES } from './error-messages'; +const isNodeEnvironment = typeof window === 'undefined'; + export function httpClient(options: HttpClientParams): AxiosInstance { const defaultConfig = { insecure: false, @@ -11,8 +13,8 @@ export function httpClient(options: HttpClientParams): AxiosInstance { headers: {} as AxiosRequestHeaders, basePath: '', proxy: false as const, - httpAgent: false, - httpsAgent: false, + httpAgent: isNodeEnvironment ? new (require('http').Agent)({ keepAlive: true }) : false, + httpsAgent: isNodeEnvironment ? new (require('https').Agent)({ keepAlive: true }) : false, timeout: 30000, logHandler: (level: string, data?: any) => { if (level === 'error') { diff --git a/test/contentstack-core.node-agent.spec.ts b/test/contentstack-core.node-agent.spec.ts new file mode 100644 index 0000000..508345b --- /dev/null +++ b/test/contentstack-core.node-agent.spec.ts @@ -0,0 +1,22 @@ +/** + * @jest-environment node + */ +import http from 'http'; +import https from 'https'; +import { httpClient } from '../src/lib/contentstack-core'; + +describe('httpClient default connection agents (Node environment)', () => { + it('should default httpAgent to a keepAlive http.Agent when not explicitly provided', () => { + const instance = httpClient({}); + + expect(instance.defaults.httpAgent).toBeInstanceOf(http.Agent); + expect((instance.defaults.httpAgent as any).keepAlive).toBe(true); + }); + + it('should default httpsAgent to a keepAlive https.Agent when not explicitly provided', () => { + const instance = httpClient({}); + + expect(instance.defaults.httpsAgent).toBeInstanceOf(https.Agent); + expect((instance.defaults.httpsAgent as any).keepAlive).toBe(true); + }); +}); diff --git a/test/contentstack-core.spec.ts b/test/contentstack-core.spec.ts index 0c4da51..abd0e7b 100644 --- a/test/contentstack-core.spec.ts +++ b/test/contentstack-core.spec.ts @@ -77,6 +77,29 @@ describe('contentstackCore', () => { }); }); + describe('connection agents', () => { + it.each(['httpAgent', 'httpsAgent'])( + 'should preserve an explicitly provided %s instead of defaulting it', + (agentOption) => { + const customAgent = { custom: true }; + const options = { [agentOption]: customAgent }; + + const instance = httpClient(options as any); + + expect((instance.defaults as any)[agentOption]).toEqual(customAgent); + } + ); + + it.each(['httpAgent', 'httpsAgent'])('should default %s to false in a browser-like environment', (agentOption) => { + // This spec file runs under jsdom (see jest.preset.js), so `window` is + // already defined here - matching a real browser, unlike the Node-only + // agent behavior covered in contentstack-core.node-agent.spec.ts. + const instance = httpClient({}); + + expect((instance.defaults as any)[agentOption]).toBe(false); + }); + }); + describe('config.headers', () => { it('should include apiKey in headers when provided', () => { const options = { From b62aa17ac4a215431c94fe5f2182d9ab072c00dc Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 29 Jul 2026 14:35:49 +0530 Subject: [PATCH 5/8] refactor: extract keep-alive agent creation into a helper function Removes duplication between the httpAgent and httpsAgent defaults. DX-9991 --- src/lib/contentstack-core.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/contentstack-core.ts b/src/lib/contentstack-core.ts index 1c469ef..8a2aa2d 100644 --- a/src/lib/contentstack-core.ts +++ b/src/lib/contentstack-core.ts @@ -6,6 +6,15 @@ import { ERROR_MESSAGES } from './error-messages'; const isNodeEnvironment = typeof window === 'undefined'; +// Guarded require: keeps 'http'/'https' out of browser bundles, which have no browser field of their own to redirect this. +function createKeepAliveAgent(moduleName: 'http' | 'https') { + if (!isNodeEnvironment) { + return false as const; + } + + return new (require(moduleName).Agent)({ keepAlive: true }); +} + export function httpClient(options: HttpClientParams): AxiosInstance { const defaultConfig = { insecure: false, @@ -13,8 +22,8 @@ export function httpClient(options: HttpClientParams): AxiosInstance { headers: {} as AxiosRequestHeaders, basePath: '', proxy: false as const, - httpAgent: isNodeEnvironment ? new (require('http').Agent)({ keepAlive: true }) : false, - httpsAgent: isNodeEnvironment ? new (require('https').Agent)({ keepAlive: true }) : false, + httpAgent: createKeepAliveAgent('http'), + httpsAgent: createKeepAliveAgent('https'), timeout: 30000, logHandler: (level: string, data?: any) => { if (level === 'error') { From cf5a7473a22ca2e8b662e3e2042f65cef51c7d50 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 31 Jul 2026 11:06:52 +0530 Subject: [PATCH 6/8] fix: isolate timeout classification test from default retry behavior Merging development (with the timeout classification fix) surfaced an interaction: the classification test didn't set retryCondition, so it now picks up the new default retryCondition (which retries ECONNABORTED), taking the retry path instead of the classification path it was meant to test. Same fix as already applied to the other pre-existing ECONNABORTED test - explicitly disable retryCondition to isolate the behavior under test. DX-9991 --- test/retryPolicy/delivery-sdk-handlers.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index b374b91..fef8945 100644 --- a/test/retryPolicy/delivery-sdk-handlers.spec.ts +++ b/test/retryPolicy/delivery-sdk-handlers.spec.ts @@ -267,7 +267,9 @@ describe('retryResponseErrorHandler', () => { }); it('should classify a request timeout distinctly instead of as an unknown error', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, code: 'ECONNABORTED' }; - const config = { retryLimit: 5, timeout: 1000 }; + // retryCondition explicitly disabled to isolate the non-retried classification path - + // ECONNABORTED is retried by default now (see "should retry transient network error" tests). + const config = { retryLimit: 5, timeout: 1000, retryCondition: () => false }; const client = axios.create(); let thrown: any; From 2c24f4317f479bd9c936a391efbcce4a92b12af9 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 31 Jul 2026 11:20:15 +0530 Subject: [PATCH 7/8] chore: bump version to 1.5.0 and update changelog Bundles the three DX-9991 fixes: timeout classification, default retry for transient network errors, and default keep-alive connection agents. DX-9991 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4877437..ad6d639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Change log +### Version: 1.5.0 +#### Date: July-31-2026 + - Fix: Classify request timeouts (`ECONNABORTED`) distinctly instead of a generic `UNKNOWN_ERROR`, preserving the real error code and message + - Fix: Retry transient network-level errors (`ECONNABORTED`, `ETIMEDOUT`, `ECONNRESET`, `EPIPE`, `EAI_AGAIN`) by default when there is no HTTP response + - Enhancement: Default `httpAgent`/`httpsAgent` to `keepAlive: true` connection agents in Node environments, reducing connection-setup overhead under concurrent request load + ### Version: 1.4.1 #### Date: June-29-2026 - Fix: upgrade dependencies diff --git a/package-lock.json b/package-lock.json index 540d1f7..3d441c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/core", - "version": "1.4.1", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/core", - "version": "1.4.1", + "version": "1.5.0", "license": "MIT", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 5da096f..e4d282b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/core", - "version": "1.4.1", + "version": "1.5.0", "type": "commonjs", "main": "./dist/cjs/src/index.js", "types": "./dist/cjs/src/index.d.ts", From ffd153d10e46d0b24125f2357cba7b6b9b39410f Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 31 Jul 2026 11:22:32 +0530 Subject: [PATCH 8/8] chore: update changelog for version 1.5.0 with correct release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6d639..4bf7f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Change log ### Version: 1.5.0 -#### Date: July-31-2026 +#### Date: August-03-2026 - Fix: Classify request timeouts (`ECONNABORTED`) distinctly instead of a generic `UNKNOWN_ERROR`, preserving the real error code and message - Fix: Retry transient network-level errors (`ECONNABORTED`, `ETIMEDOUT`, `ECONNRESET`, `EPIPE`, `EAI_AGAIN`) by default when there is no HTTP response - Enhancement: Default `httpAgent`/`httpsAgent` to `keepAlive: true` connection agents in Node environments, reducing connection-setup overhead under concurrent request load