diff --git a/CHANGELOG.md b/CHANGELOG.md index 4877437..4bf7f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Change log +### Version: 1.5.0 +#### 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 + ### 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", diff --git a/src/lib/contentstack-core.ts b/src/lib/contentstack-core.ts index 69f7b3c..8a2aa2d 100644 --- a/src/lib/contentstack-core.ts +++ b/src/lib/contentstack-core.ts @@ -4,6 +4,17 @@ import axios, { AxiosRequestHeaders, getAdapter } from 'axios'; import { AxiosInstance, HttpClientParams } from './types'; 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, @@ -11,8 +22,8 @@ export function httpClient(options: HttpClientParams): AxiosInstance { headers: {} as AxiosRequestHeaders, basePath: '', proxy: false as const, - httpAgent: false, - httpsAgent: false, + httpAgent: createKeepAliveAgent('http'), + httpsAgent: createKeepAliveAgent('https'), timeout: 30000, logHandler: (level: string, data?: any) => { if (level === 'error') { diff --git a/src/lib/retryPolicy/delivery-sdk-handlers.ts b/src/lib/retryPolicy/delivery-sdk-handlers.ts index ba36848..0671f03 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'; @@ -9,10 +8,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; @@ -59,12 +61,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/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 = { diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index f349656..fef8945 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', () => { @@ -120,6 +121,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 }, @@ -201,23 +248,43 @@ 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 }; + // 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); 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' }; + // 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; + 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 = { config: { retryOnError: true, retryCount: 5 },