diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f53cb..4ed1ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### Version: 5.6.0 +#### Date: +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. +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. + ### Version: 5.5.1 #### Date: Aug-03-2026 Fix: Bump `@contentstack/core` to `^1.5.0`: diff --git a/package.json b/package.json index db729ed..9624820 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/delivery-sdk", - "version": "5.5.1", + "version": "5.6.0", "type": "module", "license": "MIT", "engines": { diff --git a/src/common/utils.ts b/src/common/utils.ts index ca86d9b..d8e6346 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -15,6 +15,35 @@ export function isBrowser() { return (typeof window !== "undefined"); } +/** + * Node.js/libuv error codes that represent transient, retryable network-layer + * failures. All occur before an HTTP response is received (error.response is + * undefined). ECONNABORTED is excluded — @contentstack/core handles it as a + * structured TIMEOUT error and it should not be retried here. + */ +export const TRANSIENT_NETWORK_ERROR_CODES: ReadonlySet = new Set([ + 'ENOTFOUND', // DNS resolution failed + 'ENETUNREACH', // no route to host + 'ECONNRESET', // connection reset mid-flight + 'ECONNREFUSED', // port closed / service not listening + 'EAI_AGAIN', // DNS server returned SERVFAIL (transient) + 'ETIMEDOUT', // OS-level connection timeout + 'EHOSTUNREACH', // no route to host at IP layer + 'ENETDOWN', // local network interface down +]); + +/** + * 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..c69a742 100644 --- a/src/stack/contentstack.ts +++ b/src/stack/contentstack.ts @@ -172,9 +172,21 @@ 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) => { + try { + if (config.retryCondition?.(error)) return true; + } catch (e) { + 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.`); + } + 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..0fda843 --- /dev/null +++ b/test/unit/network-error-retry.spec.ts @@ -0,0 +1,227 @@ +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 (axios timeout) is NOT retried — core classifies it as a structured TIMEOUT error', async () => { + // ECONNABORTED is excluded from TRANSIENT_NETWORK_ERROR_CODES so that + // @contentstack/core can surface it as { error_code: "TIMEOUT" } (#239). + // Retrying it here would bypass that structured classification. + 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(); + }); + + it('(e) ENETUNREACH and ETIMEDOUT (the customer-reported codes) are retried', async () => { + for (const code of ['ENETUNREACH', 'ETIMEDOUT'] as const) { + 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(); + const mock = new MockAdapter(client); + + mock + .onGet('/content_types/test') + .replyOnce(dnsError(code)) + .onGet('/content_types/test') + .reply(200, { content_types: [] }); + + const res = await client.get('/content_types/test'); + expect(res.status).toBe(200); + mock.restore(); + } + }); + + it('(f) EAI_AGAIN (DNS servfail, intermittent) is retried', 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('EAI_AGAIN')) + .onGet('/content_types/test') + .reply(200, { content_types: [] }); + + const res = await client.get('/content_types/test'); + expect(res.status).toBe(200); + }); + + it('(g) retryCondition that throws is caught — warning logged and SDK falls back to default retry', async () => { + const warnMessages: string[] = []; + const throwingCondition = () => { throw new Error('boom'); }; + const config: StackConfig = { + apiKey: 'TEST-API-KEY', + deliveryToken: 'TEST-DELIVERY-TOKEN', + environment: 'TEST-ENVIRONMENT', + retryDelay: 10, + retryCondition: throwingCondition, + logHandler: (level: string, msg: any) => { + if (level === 'warn') warnMessages.push(msg); + }, + }; + const stack = Contentstack.stack(config); + const client = stack.getClient(); + mockClient = new MockAdapter(client); + + // SDK should fall back to default network-error retry and succeed. + 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); + expect(warnMessages.length).toBeGreaterThan(0); + expect(warnMessages[0]).toContain('[Contentstack SDK]'); + expect(warnMessages[0]).toContain('boom'); + }); + + it('(i) retryOnError: false disables network-error retries — ENOTFOUND throws immediately', async () => { + const config: StackConfig = { + apiKey: 'TEST-API-KEY', + deliveryToken: 'TEST-DELIVERY-TOKEN', + environment: 'TEST-ENVIRONMENT', + retryOnError: false, + retryDelay: 10, + }; + const stack = Contentstack.stack(config); + const client = stack.getClient(); + mockClient = new MockAdapter(client); + + // Second route intentionally registered — if the SDK retried it would succeed, + // proving the test would only pass when retryOnError: false truly disables retry. + mockClient + .onGet('/content_types/test') + .replyOnce(dnsError('ENOTFOUND')) + .onGet('/content_types/test') + .reply(200, { content_types: [] }); + + await expect(client.get('/content_types/test')).rejects.toBeDefined(); + }); + + it('(j) retryLimit: 0 disables network-error retries — ENOTFOUND throws immediately', async () => { + const config: StackConfig = { + apiKey: 'TEST-API-KEY', + deliveryToken: 'TEST-DELIVERY-TOKEN', + environment: 'TEST-ENVIRONMENT', + retryLimit: 0, + retryDelay: 10, + }; + const stack = Contentstack.stack(config); + const client = stack.getClient(); + mockClient = new MockAdapter(client); + + // Second route intentionally registered — if the SDK retried it would succeed, + // proving the test would only pass when retryLimit: 0 truly disables retry. + mockClient + .onGet('/content_types/test') + .replyOnce(dnsError('ENOTFOUND')) + .onGet('/content_types/test') + .reply(200, { content_types: [] }); + + await expect(client.get('/content_types/test')).rejects.toBeDefined(); + }); +});