Skip to content

Commit 7a4eaec

Browse files
committed
fix-today
1 parent 4c7eb3b commit 7a4eaec

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

src/common/utils.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,42 @@ export function isBrowser() {
1515
return (typeof window !== "undefined");
1616
}
1717

18+
/**
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.
29+
*/
30+
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',
40+
]);
41+
42+
/**
43+
* Determines whether an error represents a transient, retryable network-layer
44+
* failure (e.g. DNS lookup failure, connection reset), used to build the SDK's
45+
* default retry behavior so a single blip doesn't crash the caller (e.g. a
46+
* Next.js static build) instead of being silently retried.
47+
* @param {any} error - The error thrown by the underlying HTTP client (Axios)
48+
* @returns {boolean} True if `error.code` matches a known transient network error code
49+
*/
50+
export function isTransientNetworkError(error: any): boolean {
51+
return !!error && typeof error.code === 'string' && TRANSIENT_NETWORK_ERROR_CODES.has(error.code);
52+
}
53+
1854
/**
1955
* Encodes query parameters recursively, handling nested objects
2056
* @param {params} params - Query parameters object to encode

src/stack/contentstack.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,19 @@ export function stack(config: StackConfig): StackClass {
172172
}
173173
}
174174

175-
// Retry policy handlers
175+
// Retry policy handlers.
176+
// Network-layer errors (DNS failures, connection resets, etc.) are retried
177+
// by default, composed on top of any user-supplied retryCondition. `config`
178+
// itself is never mutated, so stack.config / client.defaults keep reflecting
179+
// exactly what the consumer passed in.
180+
const combinedRetryCondition = (error: any) => {
181+
if (config.retryCondition && config.retryCondition(error)) {
182+
return true;
183+
}
184+
return Utility.isTransientNetworkError(error);
185+
};
176186
const errorHandler = (error: any) => {
177-
return retryResponseErrorHandler(error, config, client);
187+
return retryResponseErrorHandler(error, { ...config, retryCondition: combinedRetryCondition }, client);
178188
};
179189
client.interceptors.request.use(retryRequestHandler);
180190
client.interceptors.response.use(retryResponseHandler, errorHandler);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import * as Contentstack from '../../src/stack';
2+
import { StackConfig } from '../../src/common/types';
3+
import MockAdapter from 'axios-mock-adapter';
4+
5+
describe('Default network-error retry behavior', () => {
6+
let mockClient: MockAdapter | undefined;
7+
8+
afterEach(() => {
9+
mockClient?.restore();
10+
mockClient = undefined;
11+
});
12+
13+
const dnsError = (code: string) => (config: any) =>
14+
Promise.reject(
15+
Object.assign(new Error(`getaddrinfo ${code} example.com`), {
16+
code,
17+
config,
18+
isAxiosError: true,
19+
})
20+
);
21+
22+
it('(a) retries and succeeds after a single transient ENOTFOUND failure with no custom retryCondition', async () => {
23+
const config: StackConfig = {
24+
apiKey: 'test-api-key',
25+
deliveryToken: 'test-delivery-token',
26+
environment: 'test-environment',
27+
retryDelay: 10,
28+
};
29+
const stack = Contentstack.stack(config);
30+
const client = stack.getClient();
31+
mockClient = new MockAdapter(client);
32+
33+
mockClient
34+
.onGet('/content_types/test')
35+
.replyOnce(dnsError('ENOTFOUND'))
36+
.onGet('/content_types/test')
37+
.reply(200, { content_types: [] });
38+
39+
const res = await client.get('/content_types/test');
40+
expect(res.status).toBe(200);
41+
});
42+
43+
it('(b) still fails after retryLimit is exhausted on a permanent network failure', async () => {
44+
const config: StackConfig = {
45+
apiKey: 'test-api-key',
46+
deliveryToken: 'test-delivery-token',
47+
environment: 'test-environment',
48+
retryLimit: 2,
49+
retryDelay: 10,
50+
};
51+
const stack = Contentstack.stack(config);
52+
const client = stack.getClient();
53+
mockClient = new MockAdapter(client);
54+
55+
mockClient.onGet('/content_types/test').reply(dnsError('ENOTFOUND'));
56+
57+
await expect(client.get('/content_types/test')).rejects.toBeDefined();
58+
});
59+
60+
it('(c) composes with a user-supplied retryCondition without replacing it', async () => {
61+
const userCondition = jest.fn((error: any) => error?.response?.status === 500);
62+
const config: StackConfig = {
63+
apiKey: 'test-api-key',
64+
deliveryToken: 'test-delivery-token',
65+
environment: 'test-environment',
66+
retryDelay: 10,
67+
retryCondition: userCondition,
68+
};
69+
const stack = Contentstack.stack(config);
70+
const client = stack.getClient();
71+
mockClient = new MockAdapter(client);
72+
73+
mockClient
74+
.onGet('/content_types/test')
75+
.replyOnce(dnsError('ECONNRESET'))
76+
.onGet('/content_types/test')
77+
.reply(200, { content_types: [] });
78+
79+
const res = await client.get('/content_types/test');
80+
expect(res.status).toBe(200);
81+
// config is never mutated — stack.config.retryCondition stays the exact
82+
// user-supplied function, matching the identity assertion already made
83+
// by test/unit/retry-configuration.spec.ts.
84+
expect(stack.config.retryCondition).toBe(userCondition);
85+
});
86+
87+
it('(d) ECONNABORTED/timeout errors are unaffected by the new network-retry path', async () => {
88+
const config: StackConfig = {
89+
apiKey: 'test-api-key',
90+
deliveryToken: 'test-delivery-token',
91+
environment: 'test-environment',
92+
retryDelay: 10,
93+
};
94+
const stack = Contentstack.stack(config);
95+
const client = stack.getClient();
96+
mockClient = new MockAdapter(client);
97+
98+
mockClient.onGet('/content_types/test').timeout();
99+
100+
await expect(client.get('/content_types/test')).rejects.toBeDefined();
101+
});
102+
});

0 commit comments

Comments
 (0)