Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b6e5b20
Merge pull request #232 from contentstack/main
harshitha-cstk May 25, 2026
606c6a5
Merge pull request #233 from contentstack/main
harshitha-cstk Jun 24, 2026
b235229
fix: classify request timeouts distinctly instead of unknown error
reeshika-h Jul 28, 2026
d911f4c
refactor: drop no-throw-literal eslint-disable
reeshika-h Jul 28, 2026
ba7bf3d
fix: retry transient network errors by default
reeshika-h Jul 28, 2026
667e212
fix: default to keep-alive connection agents in node environments
reeshika-h Jul 29, 2026
b62aa17
refactor: extract keep-alive agent creation into a helper function
reeshika-h Jul 29, 2026
bf50ee8
Merge pull request #239 from contentstack/fix/DX-9991-timeout-error-h…
reeshika-h Jul 31, 2026
cf71354
Merge remote-tracking branch 'origin/development' into fix/DX-9991-re…
reeshika-h Jul 31, 2026
cf5a747
fix: isolate timeout classification test from default retry behavior
reeshika-h Jul 31, 2026
2b07246
Merge pull request #240 from contentstack/fix/DX-9991-retry-network-e…
reeshika-h Jul 31, 2026
af1facf
Merge remote-tracking branch 'origin/development' into fix/DX-9991-ke…
reeshika-h Jul 31, 2026
9576772
Merge pull request #241 from contentstack/fix/DX-9991-keepalive-conne…
reeshika-h Jul 31, 2026
2c24f43
chore: bump version to 1.5.0 and update changelog
reeshika-h Jul 31, 2026
ffd153d
chore: update changelog for version 1.5.0 with correct release date
reeshika-h Jul 31, 2026
2830175
Merge pull request #243 from contentstack/chore/DX-9991-release-1.5.0
reeshika-h Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 13 additions & 2 deletions src/lib/contentstack-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@ 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,
retryOnError: true,
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') {
Expand Down
13 changes: 6 additions & 7 deletions src/lib/retryPolicy/delivery-sdk-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-throw-literal */
import axios, { InternalAxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
import { ERROR_MESSAGES } from '../error-messages';

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions test/contentstack-core.node-agent.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 23 additions & 0 deletions test/contentstack-core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
85 changes: 76 additions & 9 deletions test/retryPolicy/delivery-sdk-handlers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down
Loading