Skip to content

Commit 1e0fb5e

Browse files
authored
Merge branch 'main' into dependabot/npm_and_yarn/fast-uri-3.1.4
2 parents 3ce846f + 124b694 commit 1e0fb5e

9 files changed

Lines changed: 152 additions & 21 deletions

File tree

‎.github/workflows/sca-scan.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ on:
55
jobs:
66
security-sca:
77
runs-on: ubuntu-latest
8+
permissions:
9+
contents: read
10+
pull-requests: write
811
steps:
912
- uses: actions/checkout@master
1013
- name: Run Snyk to check for vulnerabilities

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## Change log
22

3+
### Version: 1.5.0
4+
#### Date: August-03-2026
5+
- Fix: Classify request timeouts (`ECONNABORTED`) distinctly instead of a generic `UNKNOWN_ERROR`, preserving the real error code and message
6+
- Fix: Retry transient network-level errors (`ECONNABORTED`, `ETIMEDOUT`, `ECONNRESET`, `EPIPE`, `EAI_AGAIN`) by default when there is no HTTP response
7+
- Enhancement: Default `httpAgent`/`httpsAgent` to `keepAlive: true` connection agents in Node environments, reducing connection-setup overhead under concurrent request load
8+
39
### Version: 1.4.1
410
#### Date: June-29-2026
511
- Fix: upgrade dependencies

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@contentstack/core",
3-
"version": "1.4.1",
3+
"version": "1.5.0",
44
"type": "commonjs",
55
"main": "./dist/cjs/src/index.js",
66
"types": "./dist/cjs/src/index.d.ts",

‎src/lib/contentstack-core.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,26 @@ import axios, { AxiosRequestHeaders, getAdapter } from 'axios';
44
import { AxiosInstance, HttpClientParams } from './types';
55
import { ERROR_MESSAGES } from './error-messages';
66

7+
const isNodeEnvironment = typeof window === 'undefined';
8+
9+
// Guarded require: keeps 'http'/'https' out of browser bundles, which have no browser field of their own to redirect this.
10+
function createKeepAliveAgent(moduleName: 'http' | 'https') {
11+
if (!isNodeEnvironment) {
12+
return false as const;
13+
}
14+
15+
return new (require(moduleName).Agent)({ keepAlive: true });
16+
}
17+
718
export function httpClient(options: HttpClientParams): AxiosInstance {
819
const defaultConfig = {
920
insecure: false,
1021
retryOnError: true,
1122
headers: {} as AxiosRequestHeaders,
1223
basePath: '',
1324
proxy: false as const,
14-
httpAgent: false,
15-
httpsAgent: false,
25+
httpAgent: createKeepAliveAgent('http'),
26+
httpsAgent: createKeepAliveAgent('https'),
1627
timeout: 30000,
1728
logHandler: (level: string, data?: any) => {
1829
if (level === 'error') {

‎src/lib/retryPolicy/delivery-sdk-handlers.ts‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-throw-literal */
21
import axios, { InternalAxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
32
import { ERROR_MESSAGES } from '../error-messages';
43

@@ -9,10 +8,13 @@ declare module 'axios' {
98
}
109
}
1110

11+
const TRANSIENT_NETWORK_ERROR_CODES = ['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'EPIPE', 'EAI_AGAIN'];
12+
1213
const defaultConfig = {
1314
maxRequests: 5,
1415
retryLimit: 5,
1516
retryDelay: 300,
17+
retryCondition: (error: any) => !error.response && TRANSIENT_NETWORK_ERROR_CODES.includes(error.code),
1618
};
1719

1820
const DEFAULT_RETRY_DELAY_MS = 300;
@@ -59,12 +61,9 @@ export const retryResponseErrorHandler = (error: any, config: any, axiosInstance
5961
}
6062

6163
if (error.code === 'ECONNABORTED') {
62-
const customError = {
63-
error_message: ERROR_MESSAGES.RETRY.TIMEOUT_EXCEEDED(config.timeout),
64-
error_code: ERROR_MESSAGES.ERROR_CODES.TIMEOUT,
65-
errors: null,
66-
};
67-
throw customError; // Throw customError object
64+
const timeoutError = new Error(ERROR_MESSAGES.RETRY.TIMEOUT_EXCEEDED(config.timeout));
65+
(timeoutError as any).code = ERROR_MESSAGES.ERROR_CODES.TIMEOUT;
66+
throw timeoutError;
6867
}
6968

7069
throw error;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import http from 'http';
5+
import https from 'https';
6+
import { httpClient } from '../src/lib/contentstack-core';
7+
8+
describe('httpClient default connection agents (Node environment)', () => {
9+
it('should default httpAgent to a keepAlive http.Agent when not explicitly provided', () => {
10+
const instance = httpClient({});
11+
12+
expect(instance.defaults.httpAgent).toBeInstanceOf(http.Agent);
13+
expect((instance.defaults.httpAgent as any).keepAlive).toBe(true);
14+
});
15+
16+
it('should default httpsAgent to a keepAlive https.Agent when not explicitly provided', () => {
17+
const instance = httpClient({});
18+
19+
expect(instance.defaults.httpsAgent).toBeInstanceOf(https.Agent);
20+
expect((instance.defaults.httpsAgent as any).keepAlive).toBe(true);
21+
});
22+
});

‎test/contentstack-core.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,29 @@ describe('contentstackCore', () => {
7777
});
7878
});
7979

80+
describe('connection agents', () => {
81+
it.each(['httpAgent', 'httpsAgent'])(
82+
'should preserve an explicitly provided %s instead of defaulting it',
83+
(agentOption) => {
84+
const customAgent = { custom: true };
85+
const options = { [agentOption]: customAgent };
86+
87+
const instance = httpClient(options as any);
88+
89+
expect((instance.defaults as any)[agentOption]).toEqual(customAgent);
90+
}
91+
);
92+
93+
it.each(['httpAgent', 'httpsAgent'])('should default %s to false in a browser-like environment', (agentOption) => {
94+
// This spec file runs under jsdom (see jest.preset.js), so `window` is
95+
// already defined here - matching a real browser, unlike the Node-only
96+
// agent behavior covered in contentstack-core.node-agent.spec.ts.
97+
const instance = httpClient({});
98+
99+
expect((instance.defaults as any)[agentOption]).toBe(false);
100+
});
101+
});
102+
80103
describe('config.headers', () => {
81104
it('should include apiKey in headers when provided', () => {
82105
const options = {

‎test/retryPolicy/delivery-sdk-handlers.spec.ts‎

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getRetryDelay,
99
} from '../../src/lib/retryPolicy/delivery-sdk-handlers';
1010
import MockAdapter from 'axios-mock-adapter';
11+
import { APIError } from '../../src/lib/api-error';
1112

1213
describe('retryRequestHandler', () => {
1314
it('should add retryCount to the request config', () => {
@@ -120,6 +121,52 @@ describe('retryResponseErrorHandler', () => {
120121
jest.useRealTimers();
121122
});
122123

124+
it.each(['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'EPIPE', 'EAI_AGAIN'])(
125+
'should retry transient network error %s by default when no custom retryCondition is configured',
126+
async (code) => {
127+
const error = {
128+
config: { retryOnError: true, retryCount: 1, method: 'get', url: '/default-retry' },
129+
code,
130+
message: `simulated ${code}`,
131+
};
132+
// No retryCondition here - mirrors the raw StackConfig a real stack() caller passes.
133+
const config = { retryLimit: 3, retryDelay: 50 };
134+
const client = axios.create();
135+
136+
mock.onGet('/default-retry').reply(200, { success: true });
137+
138+
jest.useFakeTimers();
139+
140+
const responsePromise = retryResponseErrorHandler(error, config, client);
141+
jest.advanceTimersByTime(50);
142+
143+
const response = (await responsePromise) as AxiosResponse;
144+
expect(response.status).toBe(200);
145+
146+
jest.useRealTimers();
147+
}
148+
);
149+
150+
it.each(['ENOTFOUND', 'ECONNREFUSED'])(
151+
'should not retry non-transient network error %s by default when no custom retryCondition is configured',
152+
async (code) => {
153+
const error = {
154+
config: { retryOnError: true, retryCount: 1 },
155+
code,
156+
message: `simulated ${code}`,
157+
};
158+
const config = { retryLimit: 3 };
159+
const client = axios.create();
160+
161+
try {
162+
await retryResponseErrorHandler(error, config, client);
163+
fail(`Expected retryResponseErrorHandler to throw for ${code}`);
164+
} catch (err) {
165+
expect(err).toEqual(error);
166+
}
167+
}
168+
);
169+
123170
it('should rethrow network errors when retryCondition returns false', async () => {
124171
const error = {
125172
config: { retryOnError: true, retryCount: 1 },
@@ -201,23 +248,43 @@ describe('retryResponseErrorHandler', () => {
201248
jest.useRealTimers();
202249
});
203250

204-
it('should resolve the promise to 408 error if retryOnError is true and error code is ECONNABORTED', async () => {
251+
it('should throw a real Error with the timeout duration and a TIMEOUT code when ECONNABORTED occurs', async () => {
205252
const error = { config: { retryOnError: true, retryCount: 1 }, code: 'ECONNABORTED' };
206-
const config = { retryLimit: 5, timeout: 1000 };
253+
// retryCondition explicitly disabled here to isolate the non-retried ECONNABORTED throw path,
254+
// since ECONNABORTED is retried by default now (see "should retry transient network error" tests).
255+
const config = { retryLimit: 5, timeout: 1000, retryCondition: () => false };
207256
const client = axios.create();
208257
try {
209258
await retryResponseErrorHandler(error, config, client);
210259
fail('Expected retryResponseErrorHandler to throw an error');
211-
} catch (err) {
212-
expect(err).toEqual(
213-
expect.objectContaining({
214-
error_code: 408,
215-
error_message: `Request timeout of ${config.timeout}ms exceeded. Please try again or increase the timeout value in your configuration.`,
216-
errors: null,
217-
})
260+
} catch (err: any) {
261+
expect(err).toBeInstanceOf(Error);
262+
expect(err.message).toBe(
263+
`Request timeout of ${config.timeout}ms exceeded. Please try again or increase the timeout value in your configuration.`
218264
);
265+
expect(err.code).toBe(408);
219266
}
220267
});
268+
it('should classify a request timeout distinctly instead of as an unknown error', async () => {
269+
const error = { config: { retryOnError: true, retryCount: 1 }, code: 'ECONNABORTED' };
270+
// retryCondition explicitly disabled to isolate the non-retried classification path -
271+
// ECONNABORTED is retried by default now (see "should retry transient network error" tests).
272+
const config = { retryLimit: 5, timeout: 1000, retryCondition: () => false };
273+
const client = axios.create();
274+
275+
let thrown: any;
276+
try {
277+
await retryResponseErrorHandler(error, config, client);
278+
fail('Expected retryResponseErrorHandler to throw an error');
279+
} catch (err) {
280+
thrown = err;
281+
}
282+
283+
const apiError = APIError.fromAxiosError(thrown);
284+
285+
expect(apiError.error_code).toBe(408);
286+
expect(apiError.error_message).toContain('timeout');
287+
});
221288
it('should reject the promise if response status is 429 and retryCount exceeds retryLimit', async () => {
222289
const error = {
223290
config: { retryOnError: true, retryCount: 5 },

0 commit comments

Comments
 (0)