Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,42 @@ export function isBrowser() {
return (typeof window !== "undefined");
}

/**
* Node.js/libuv error codes representing transient, retryable network-layer
* failures (DNS resolution, connection reset/refused, no route to host, etc.),
* plus Axios's own client-side timeout/abort signal ('ECONNABORTED'). All of
* these occur before an HTTP response is received, so `error.response` is
* undefined for all of them.
*
* 'ECONNABORTED' is included deliberately: @contentstack/core's own timeout
* handling never retries it (it throws immediately on the first occurrence),
* so without this, a single transient timeout has the same crash-the-caller
* effect as an unretried DNS failure.
*/
export const TRANSIENT_NETWORK_ERROR_CODES: ReadonlySet<string> = new Set([
'ENOTFOUND',
'ENETUNREACH',
'ECONNRESET',
'ECONNREFUSED',
'EAI_AGAIN',
'ETIMEDOUT',
'EHOSTUNREACH',
'ENETDOWN',
'ECONNABORTED',
]);

/**
* Determines whether an error represents a transient, retryable network-layer
* failure (e.g. DNS lookup failure, connection reset), used to build the SDK's
* default retry behavior so a single blip doesn't crash the caller (e.g. a
* Next.js static build) instead of being silently retried.
* @param {any} error - The error thrown by the underlying HTTP client (Axios)
* @returns {boolean} True if `error.code` matches a known transient network error code
*/
export function isTransientNetworkError(error: any): boolean {
return !!error && typeof error.code === 'string' && TRANSIENT_NETWORK_ERROR_CODES.has(error.code);
}

/**
* Encodes query parameters recursively, handling nested objects
* @param {params} params - Query parameters object to encode
Expand Down
14 changes: 12 additions & 2 deletions src/stack/contentstack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,19 @@
}
}

// Retry policy handlers
// Retry policy handlers.
// Network-layer errors (DNS failures, connection resets, etc.) are retried
// by default, composed on top of any user-supplied retryCondition. `config`
// itself is never mutated, so stack.config / client.defaults keep reflecting
// exactly what the consumer passed in.
const combinedRetryCondition = (error: any) => {
if (config.retryCondition && config.retryCondition(error)) {
return true;

Check warning on line 182 in src/stack/contentstack.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 183 in src/stack/contentstack.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
return Utility.isTransientNetworkError(error);
};
const errorHandler = (error: any) => {
return retryResponseErrorHandler(error, config, client);
return retryResponseErrorHandler(error, { ...config, retryCondition: combinedRetryCondition }, client);
};
client.interceptors.request.use(retryRequestHandler);
client.interceptors.response.use(retryResponseHandler, errorHandler);
Expand Down
102 changes: 102 additions & 0 deletions test/unit/network-error-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as Contentstack from '../../src/stack';
import { StackConfig } from '../../src/common/types';
import MockAdapter from 'axios-mock-adapter';

describe('Default network-error retry behavior', () => {
let mockClient: MockAdapter | undefined;

afterEach(() => {
mockClient?.restore();
mockClient = undefined;
});

const dnsError = (code: string) => (config: any) =>
Promise.reject(
Object.assign(new Error(`getaddrinfo ${code} example.com`), {
code,
config,
isAxiosError: true,
})
);

it('(a) retries and succeeds after a single transient ENOTFOUND failure with no custom retryCondition', async () => {
const config: StackConfig = {
apiKey: 'test-api-key',
deliveryToken: 'test-delivery-token',
environment: 'test-environment',
retryDelay: 10,
};
const stack = Contentstack.stack(config);
const client = stack.getClient();
mockClient = new MockAdapter(client);

mockClient
.onGet('/content_types/test')
.replyOnce(dnsError('ENOTFOUND'))
.onGet('/content_types/test')
.reply(200, { content_types: [] });

const res = await client.get('/content_types/test');
expect(res.status).toBe(200);
});

it('(b) still fails after retryLimit is exhausted on a permanent network failure', async () => {
const config: StackConfig = {
apiKey: 'test-api-key',
deliveryToken: 'test-delivery-token',
environment: 'test-environment',
retryLimit: 2,
retryDelay: 10,
};
const stack = Contentstack.stack(config);
const client = stack.getClient();
mockClient = new MockAdapter(client);

mockClient.onGet('/content_types/test').reply(dnsError('ENOTFOUND'));

await expect(client.get('/content_types/test')).rejects.toBeDefined();
});

it('(c) composes with a user-supplied retryCondition without replacing it', async () => {
const userCondition = jest.fn((error: any) => error?.response?.status === 500);
const config: StackConfig = {
apiKey: 'test-api-key',
deliveryToken: 'test-delivery-token',
environment: 'test-environment',
retryDelay: 10,
retryCondition: userCondition,
};
const stack = Contentstack.stack(config);
const client = stack.getClient();
mockClient = new MockAdapter(client);

mockClient
.onGet('/content_types/test')
.replyOnce(dnsError('ECONNRESET'))
.onGet('/content_types/test')
.reply(200, { content_types: [] });

const res = await client.get('/content_types/test');
expect(res.status).toBe(200);
// config is never mutated — stack.config.retryCondition stays the exact
// user-supplied function, matching the identity assertion already made
// by test/unit/retry-configuration.spec.ts.
expect(stack.config.retryCondition).toBe(userCondition);
});

it('(d) ECONNABORTED/timeout errors are unaffected by the new network-retry path', async () => {
const config: StackConfig = {
apiKey: 'test-api-key',
deliveryToken: 'test-delivery-token',
environment: 'test-environment',
retryDelay: 10,
};
const stack = Contentstack.stack(config);
const client = stack.getClient();
mockClient = new MockAdapter(client);

mockClient.onGet('/content_types/test').timeout();

await expect(client.get('/content_types/test')).rejects.toBeDefined();
});
});
Loading