Skip to content
Merged
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: Aug-10-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.2
#### Date: Aug-05-2026
Fix: Bump `@contentstack/core` to `^1.5.1`:
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.2",
"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 and no HTTP response was received (`error.response` is absent)
*/
export function isTransientNetworkError(error: any): boolean {
return !!error && typeof error.code === 'string' && !error.response && TRANSIENT_NETWORK_ERROR_CODES.has(error.code);
}

/**
* Encodes query parameters recursively, handling nested objects
* @param {params} params - Query parameters object to encode
Expand Down
19 changes: 17 additions & 2 deletions src/stack/contentstack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,24 @@ 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.
// The user's retryCondition reference is never replaced — only wrapped.
const combinedRetryCondition = (error: any) => {
try {
if (config.retryCondition?.(error)) return true;
} catch (e) {
config.logHandler?.('warn', {
type: 'retry_condition_error',
message: `[Contentstack SDK] retryCondition callback threw: "${(e as Error)?.message ?? e}". Check your retryCondition implementation. Falling back to default network-error retry behavior.`,
error: e,
});
}
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
310 changes: 310 additions & 0 deletions test/unit/network-error-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
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 — structured warning logged and SDK falls back to default retry', async () => {
const warnPayloads: any[] = [];
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') warnPayloads.push(msg);
},
};
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);
expect(warnPayloads.length).toBeGreaterThan(0);
expect(warnPayloads[0]).toEqual(expect.objectContaining({ type: 'retry_condition_error' }));
expect(warnPayloads[0].message).toContain('[Contentstack SDK]');
expect(warnPayloads[0].message).toContain('boom');
});

it('(h1) retryCondition throws a non-Error (string) — ?? fallback logs the raw thrown value', async () => {
const warnPayloads: any[] = [];
const throwingCondition = () => { throw 'not-an-error-object'; };
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') warnPayloads.push(msg);
},
};
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);
expect(warnPayloads[0]).toEqual(expect.objectContaining({ type: 'retry_condition_error' }));
// message uses the raw thrown value via the ?? fallback since it has no .message
expect(warnPayloads[0].message).toContain('not-an-error-object');
});

it('(h) retryCondition that throws with no logHandler — SDK falls back silently without throwing', async () => {
const throwingCondition = () => { throw new Error('boom'); };
const config: StackConfig = {
apiKey: 'TEST-API-KEY',
deliveryToken: 'TEST-DELIVERY-TOKEN',
environment: 'TEST-ENVIRONMENT',
retryDelay: 10,
retryCondition: throwingCondition,
// no logHandler — exercises the logHandler?. undefined branch
};
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('(k) retryCondition returning true triggers retry even for non-transient-code errors', async () => {
// Covers the `if (config.retryCondition?.(error)) return true` branch.
const alwaysRetry = jest.fn().mockReturnValue(true);
const config: StackConfig = {
apiKey: 'TEST-API-KEY',
deliveryToken: 'TEST-DELIVERY-TOKEN',
environment: 'TEST-ENVIRONMENT',
retryDelay: 10,
retryCondition: alwaysRetry,
};
const stack = Contentstack.stack(config);
const client = stack.getClient();
mockClient = new MockAdapter(client);

// Use a generic error with no recognized code — only user retryCondition covers it.
const genericError = (cfg: any) =>
Promise.reject(Object.assign(new Error('generic'), { config: cfg, isAxiosError: true }));

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

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

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