Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentstack/delivery-sdk",
"version": "5.5.1",
"version": "5.6.0",
"type": "module",
"license": "MIT",
"engines": {
Expand Down
29 changes: 29 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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);
}
Comment on lines +43 to +45

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

// 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.
Comment on lines +175 to +179
const combinedRetryCondition = (error: any) => {
try {
if (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 182 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
} 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.`);

Check warning on line 184 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
}
Comment on lines +183 to +185
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
227 changes: 227 additions & 0 deletions test/unit/network-error-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading