From 7a4eaece242ba5a26f2dc4543a0f6aea4707763f Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Fri, 31 Jul 2026 16:39:13 +0530 Subject: [PATCH] fix-today --- src/common/utils.ts | 36 +++++++++ src/stack/contentstack.ts | 14 +++- test/unit/network-error-retry.spec.ts | 102 ++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 test/unit/network-error-retry.spec.ts diff --git a/src/common/utils.ts b/src/common/utils.ts index ca86d9b..7a1418d 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -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 = 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 diff --git a/src/stack/contentstack.ts b/src/stack/contentstack.ts index 0e320d8..f5207fb 100644 --- a/src/stack/contentstack.ts +++ b/src/stack/contentstack.ts @@ -172,9 +172,19 @@ export function stack(config: StackConfig): StackClass { } } - // 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; + } + 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); diff --git a/test/unit/network-error-retry.spec.ts b/test/unit/network-error-retry.spec.ts new file mode 100644 index 0000000..91dc6d7 --- /dev/null +++ b/test/unit/network-error-retry.spec.ts @@ -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(); + }); +});