From 7a4eaece242ba5a26f2dc4543a0f6aea4707763f Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Fri, 31 Jul 2026 16:39:13 +0530 Subject: [PATCH 1/4] 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(); + }); +}); From f9ac1f0dc259871a4f7cfaeb4145b60f29e68bb4 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Mon, 3 Aug 2026 10:46:41 +0530 Subject: [PATCH 2/4] fix(DX-10060): retry transient network errors to prevent build crashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 ++ package.json | 2 +- src/common/utils.ts | 31 +++---- src/stack/contentstack.ts | 6 +- test/unit/network-error-retry.spec.ts | 120 +++++++++++++++++++++++--- 5 files changed, 129 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a8ac8..9d7924e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### Version: 5.5.1 +#### Date: Aug-03-2026 +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.0 #### Date: Jul-27-2026 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. diff --git a/package.json b/package.json index e840e57..ed776ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/delivery-sdk", - "version": "5.5.0", + "version": "5.5.1", "type": "module", "license": "MIT", "engines": { diff --git a/src/common/utils.ts b/src/common/utils.ts index 7a1418d..d8e6346 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -16,27 +16,20 @@ export function isBrowser() { } /** - * 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. + * 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', - 'ENETUNREACH', - 'ECONNRESET', - 'ECONNREFUSED', - 'EAI_AGAIN', - 'ETIMEDOUT', - 'EHOSTUNREACH', - 'ENETDOWN', - 'ECONNABORTED', + '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 ]); /** diff --git a/src/stack/contentstack.ts b/src/stack/contentstack.ts index f5207fb..c69a742 100644 --- a/src/stack/contentstack.ts +++ b/src/stack/contentstack.ts @@ -178,8 +178,10 @@ export function stack(config: StackConfig): StackClass { // 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; + 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); }; diff --git a/test/unit/network-error-retry.spec.ts b/test/unit/network-error-retry.spec.ts index 91dc6d7..6c3b6bb 100644 --- a/test/unit/network-error-retry.spec.ts +++ b/test/unit/network-error-retry.spec.ts @@ -21,9 +21,9 @@ describe('Default network-error retry behavior', () => { 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', + apiKey: 'TEST-API-KEY', + deliveryToken: 'TEST-DELIVERY-TOKEN', + environment: 'TEST-ENVIRONMENT', retryDelay: 10, }; const stack = Contentstack.stack(config); @@ -42,9 +42,9 @@ describe('Default network-error retry behavior', () => { 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', + apiKey: 'TEST-API-KEY', + deliveryToken: 'TEST-DELIVERY-TOKEN', + environment: 'TEST-ENVIRONMENT', retryLimit: 2, retryDelay: 10, }; @@ -60,9 +60,9 @@ describe('Default network-error retry behavior', () => { 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', + apiKey: 'TEST-API-KEY', + deliveryToken: 'TEST-DELIVERY-TOKEN', + environment: 'TEST-ENVIRONMENT', retryDelay: 10, retryCondition: userCondition, }; @@ -84,11 +84,14 @@ describe('Default network-error retry behavior', () => { expect(stack.config.retryCondition).toBe(userCondition); }); - it('(d) ECONNABORTED/timeout errors are unaffected by the new network-retry path', async () => { + 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', + apiKey: 'TEST-API-KEY', + deliveryToken: 'TEST-DELIVERY-TOKEN', + environment: 'TEST-ENVIRONMENT', retryDelay: 10, }; const stack = Contentstack.stack(config); @@ -99,4 +102,95 @@ describe('Default network-error retry behavior', () => { 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) 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('(h) 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(); + }); }); From c9a16bc056f79556daf57a9b2e1abbafb17709af Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Mon, 3 Aug 2026 11:13:25 +0530 Subject: [PATCH 3/4] chore: bump version to 5.6.0 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7924e..9504f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### Version: 5.5.1 +### Version: 5.6.0 #### Date: Aug-03-2026 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. diff --git a/package.json b/package.json index ed776ee..4088e1d 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": { From 0d45b5bf6d386b42c720d486596f7f83f72e057f Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Mon, 3 Aug 2026 12:21:53 +0530 Subject: [PATCH 4/4] test(DX-10060): cover throwing retryCondition to resolve CI coverage warnings Lines 182 and 184 in contentstack.ts (the catch block and logHandler warn path) were flagged uncovered by jest-coverage-report-action. Added test (g) which exercises a retryCondition that throws, verifies the SDK falls back to default retry behaviour, and asserts the warning is emitted via logHandler. Co-Authored-By: Claude Sonnet 4.6 --- test/unit/network-error-retry.spec.ts | 35 +++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/test/unit/network-error-retry.spec.ts b/test/unit/network-error-retry.spec.ts index 6c3b6bb..0fda843 100644 --- a/test/unit/network-error-retry.spec.ts +++ b/test/unit/network-error-retry.spec.ts @@ -148,7 +148,38 @@ describe('Default network-error retry behavior', () => { expect(res.status).toBe(200); }); - it('(g) retryOnError: false disables network-error retries — ENOTFOUND throws immediately', async () => { + 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', @@ -171,7 +202,7 @@ describe('Default network-error retry behavior', () => { await expect(client.get('/content_types/test')).rejects.toBeDefined(); }); - it('(h) retryLimit: 0 disables network-error retries — ENOTFOUND throws immediately', async () => { + it('(j) retryLimit: 0 disables network-error retries — ENOTFOUND throws immediately', async () => { const config: StackConfig = { apiKey: 'TEST-API-KEY', deliveryToken: 'TEST-DELIVERY-TOKEN',