Skip to content

Commit f9ac1f0

Browse files
OMpawar-21claude
andcommitted
fix(DX-10060): retry transient network errors to prevent build crashes
Transient network-layer errors (ENOTFOUND, ENETUNREACH, ECONNRESET, ECONNREFUSED, EAI_AGAIN, ETIMEDOUT, EHOSTUNREACH, ENETDOWN) now trigger the SDK's configured retry policy instead of failing immediately. A combinedRetryCondition composes the user-supplied retryCondition with the new default network-error check. The user condition runs first; if it throws, a warning is emitted via logHandler and the SDK falls back to the default. The original config object is never mutated. ECONNABORTED is excluded — @contentstack/core classifies it as a structured TIMEOUT error and handles it separately. Resolves: SF Case #00060601 (SentinelOne) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7a4eaec commit f9ac1f0

5 files changed

Lines changed: 129 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### Version: 5.5.1
2+
#### Date: Aug-03-2026
3+
Fix: Transient network-layer errors (ENOTFOUND, ENETUNREACH, ECONNRESET, ECONNREFUSED, EAI_AGAIN, ETIMEDOUT, EHOSTUNREACH, ENETDOWN) are now retried automatically using the SDK's configured retry policy instead of failing immediately.
4+
Enhancement: User-supplied `retryCondition` is composed with the default network-error retry logic — both are honoured without either replacing the other. If `retryCondition` throws, the SDK logs a warning via `logHandler` and falls back to default retry behaviour.
5+
16
### Version: 5.5.0
27
#### Date: Jul-27-2026
38
Enhancement: Entry variants support an optional branch name as the second argument to `variants()` on `Entry` and `Entries`. When provided, the branch is sent as the `branch` request header together with `x-cs-variant-uid`. Existing `variants(uid)` and `variants(uids)` calls remain backward compatible. Added unit and API tests for variant + branch requests.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@contentstack/delivery-sdk",
3-
"version": "5.5.0",
3+
"version": "5.5.1",
44
"type": "module",
55
"license": "MIT",
66
"engines": {

src/common/utils.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,20 @@ export function isBrowser() {
1616
}
1717

1818
/**
19-
* Node.js/libuv error codes representing transient, retryable network-layer
20-
* failures (DNS resolution, connection reset/refused, no route to host, etc.),
21-
* plus Axios's own client-side timeout/abort signal ('ECONNABORTED'). All of
22-
* these occur before an HTTP response is received, so `error.response` is
23-
* undefined for all of them.
24-
*
25-
* 'ECONNABORTED' is included deliberately: @contentstack/core's own timeout
26-
* handling never retries it (it throws immediately on the first occurrence),
27-
* so without this, a single transient timeout has the same crash-the-caller
28-
* effect as an unretried DNS failure.
19+
* Node.js/libuv error codes that represent transient, retryable network-layer
20+
* failures. All occur before an HTTP response is received (error.response is
21+
* undefined). ECONNABORTED is excluded — @contentstack/core handles it as a
22+
* structured TIMEOUT error and it should not be retried here.
2923
*/
3024
export const TRANSIENT_NETWORK_ERROR_CODES: ReadonlySet<string> = new Set([
31-
'ENOTFOUND',
32-
'ENETUNREACH',
33-
'ECONNRESET',
34-
'ECONNREFUSED',
35-
'EAI_AGAIN',
36-
'ETIMEDOUT',
37-
'EHOSTUNREACH',
38-
'ENETDOWN',
39-
'ECONNABORTED',
25+
'ENOTFOUND', // DNS resolution failed
26+
'ENETUNREACH', // no route to host
27+
'ECONNRESET', // connection reset mid-flight
28+
'ECONNREFUSED', // port closed / service not listening
29+
'EAI_AGAIN', // DNS server returned SERVFAIL (transient)
30+
'ETIMEDOUT', // OS-level connection timeout
31+
'EHOSTUNREACH', // no route to host at IP layer
32+
'ENETDOWN', // local network interface down
4033
]);
4134

4235
/**

src/stack/contentstack.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,10 @@ export function stack(config: StackConfig): StackClass {
178178
// itself is never mutated, so stack.config / client.defaults keep reflecting
179179
// exactly what the consumer passed in.
180180
const combinedRetryCondition = (error: any) => {
181-
if (config.retryCondition && config.retryCondition(error)) {
182-
return true;
181+
try {
182+
if (config.retryCondition?.(error)) return true;
183+
} catch (e) {
184+
config.logHandler?.('warn', `[Contentstack SDK] retryCondition callback threw: "${(e as Error)?.message ?? e}". Check your retryCondition implementation. Falling back to default network-error retry behavior.`);
183185
}
184186
return Utility.isTransientNetworkError(error);
185187
};

test/unit/network-error-retry.spec.ts

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ describe('Default network-error retry behavior', () => {
2121

2222
it('(a) retries and succeeds after a single transient ENOTFOUND failure with no custom retryCondition', async () => {
2323
const config: StackConfig = {
24-
apiKey: 'test-api-key',
25-
deliveryToken: 'test-delivery-token',
26-
environment: 'test-environment',
24+
apiKey: 'TEST-API-KEY',
25+
deliveryToken: 'TEST-DELIVERY-TOKEN',
26+
environment: 'TEST-ENVIRONMENT',
2727
retryDelay: 10,
2828
};
2929
const stack = Contentstack.stack(config);
@@ -42,9 +42,9 @@ describe('Default network-error retry behavior', () => {
4242

4343
it('(b) still fails after retryLimit is exhausted on a permanent network failure', async () => {
4444
const config: StackConfig = {
45-
apiKey: 'test-api-key',
46-
deliveryToken: 'test-delivery-token',
47-
environment: 'test-environment',
45+
apiKey: 'TEST-API-KEY',
46+
deliveryToken: 'TEST-DELIVERY-TOKEN',
47+
environment: 'TEST-ENVIRONMENT',
4848
retryLimit: 2,
4949
retryDelay: 10,
5050
};
@@ -60,9 +60,9 @@ describe('Default network-error retry behavior', () => {
6060
it('(c) composes with a user-supplied retryCondition without replacing it', async () => {
6161
const userCondition = jest.fn((error: any) => error?.response?.status === 500);
6262
const config: StackConfig = {
63-
apiKey: 'test-api-key',
64-
deliveryToken: 'test-delivery-token',
65-
environment: 'test-environment',
63+
apiKey: 'TEST-API-KEY',
64+
deliveryToken: 'TEST-DELIVERY-TOKEN',
65+
environment: 'TEST-ENVIRONMENT',
6666
retryDelay: 10,
6767
retryCondition: userCondition,
6868
};
@@ -84,11 +84,14 @@ describe('Default network-error retry behavior', () => {
8484
expect(stack.config.retryCondition).toBe(userCondition);
8585
});
8686

87-
it('(d) ECONNABORTED/timeout errors are unaffected by the new network-retry path', async () => {
87+
it('(d) ECONNABORTED (axios timeout) is NOT retried — core classifies it as a structured TIMEOUT error', async () => {
88+
// ECONNABORTED is excluded from TRANSIENT_NETWORK_ERROR_CODES so that
89+
// @contentstack/core can surface it as { error_code: "TIMEOUT" } (#239).
90+
// Retrying it here would bypass that structured classification.
8891
const config: StackConfig = {
89-
apiKey: 'test-api-key',
90-
deliveryToken: 'test-delivery-token',
91-
environment: 'test-environment',
92+
apiKey: 'TEST-API-KEY',
93+
deliveryToken: 'TEST-DELIVERY-TOKEN',
94+
environment: 'TEST-ENVIRONMENT',
9295
retryDelay: 10,
9396
};
9497
const stack = Contentstack.stack(config);
@@ -99,4 +102,95 @@ describe('Default network-error retry behavior', () => {
99102

100103
await expect(client.get('/content_types/test')).rejects.toBeDefined();
101104
});
105+
106+
it('(e) ENETUNREACH and ETIMEDOUT (the customer-reported codes) are retried', async () => {
107+
for (const code of ['ENETUNREACH', 'ETIMEDOUT'] as const) {
108+
const config: StackConfig = {
109+
apiKey: 'TEST-API-KEY',
110+
deliveryToken: 'TEST-DELIVERY-TOKEN',
111+
environment: 'TEST-ENVIRONMENT',
112+
retryDelay: 10,
113+
};
114+
const stack = Contentstack.stack(config);
115+
const client = stack.getClient();
116+
const mock = new MockAdapter(client);
117+
118+
mock
119+
.onGet('/content_types/test')
120+
.replyOnce(dnsError(code))
121+
.onGet('/content_types/test')
122+
.reply(200, { content_types: [] });
123+
124+
const res = await client.get('/content_types/test');
125+
expect(res.status).toBe(200);
126+
mock.restore();
127+
}
128+
});
129+
130+
it('(f) EAI_AGAIN (DNS servfail, intermittent) is retried', async () => {
131+
const config: StackConfig = {
132+
apiKey: 'TEST-API-KEY',
133+
deliveryToken: 'TEST-DELIVERY-TOKEN',
134+
environment: 'TEST-ENVIRONMENT',
135+
retryDelay: 10,
136+
};
137+
const stack = Contentstack.stack(config);
138+
const client = stack.getClient();
139+
mockClient = new MockAdapter(client);
140+
141+
mockClient
142+
.onGet('/content_types/test')
143+
.replyOnce(dnsError('EAI_AGAIN'))
144+
.onGet('/content_types/test')
145+
.reply(200, { content_types: [] });
146+
147+
const res = await client.get('/content_types/test');
148+
expect(res.status).toBe(200);
149+
});
150+
151+
it('(g) retryOnError: false disables network-error retries — ENOTFOUND throws immediately', async () => {
152+
const config: StackConfig = {
153+
apiKey: 'TEST-API-KEY',
154+
deliveryToken: 'TEST-DELIVERY-TOKEN',
155+
environment: 'TEST-ENVIRONMENT',
156+
retryOnError: false,
157+
retryDelay: 10,
158+
};
159+
const stack = Contentstack.stack(config);
160+
const client = stack.getClient();
161+
mockClient = new MockAdapter(client);
162+
163+
// Second route intentionally registered — if the SDK retried it would succeed,
164+
// proving the test would only pass when retryOnError: false truly disables retry.
165+
mockClient
166+
.onGet('/content_types/test')
167+
.replyOnce(dnsError('ENOTFOUND'))
168+
.onGet('/content_types/test')
169+
.reply(200, { content_types: [] });
170+
171+
await expect(client.get('/content_types/test')).rejects.toBeDefined();
172+
});
173+
174+
it('(h) retryLimit: 0 disables network-error retries — ENOTFOUND throws immediately', async () => {
175+
const config: StackConfig = {
176+
apiKey: 'TEST-API-KEY',
177+
deliveryToken: 'TEST-DELIVERY-TOKEN',
178+
environment: 'TEST-ENVIRONMENT',
179+
retryLimit: 0,
180+
retryDelay: 10,
181+
};
182+
const stack = Contentstack.stack(config);
183+
const client = stack.getClient();
184+
mockClient = new MockAdapter(client);
185+
186+
// Second route intentionally registered — if the SDK retried it would succeed,
187+
// proving the test would only pass when retryLimit: 0 truly disables retry.
188+
mockClient
189+
.onGet('/content_types/test')
190+
.replyOnce(dnsError('ENOTFOUND'))
191+
.onGet('/content_types/test')
192+
.reply(200, { content_types: [] });
193+
194+
await expect(client.get('/content_types/test')).rejects.toBeDefined();
195+
});
102196
});

0 commit comments

Comments
 (0)