diff --git a/.talismanrc b/.talismanrc index f3e55f2..147c4a6 100644 --- a/.talismanrc +++ b/.talismanrc @@ -3,11 +3,15 @@ fileignoreconfig: ignore_detectors: - filecontent - filename: package-lock.json - checksum: 52799bf1f9a1c387a74baeecac6c1c08f22bb8abdd2a1f0e689d8ed374b47635 + checksum: 61066aedc29ef5bd8904d1ee2384dad828e8f9aab1a6b0360797ec7926e7f8dd - filename: .husky/pre-commit checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193 - filename: test/request.spec.ts checksum: 87afd3bb570fd52437404cbe69a39311ad8a8c73bca9d075ecf88652fd3e13f6 - filename: src/lib/request.ts checksum: 86d761c4f50fcf377e52c98e0c4db6f06be955790fc5a0f2ba8fe32a88c60825 +- filename: src/lib/retryPolicy/delivery-sdk-handlers.ts + checksum: 08ccd6342b3adbeb7b85309a034b4df4b2ad905a0cc2a3778ab483b61ba41b9e +- filename: test/retryPolicy/delivery-sdk-handlers.spec.ts + checksum: 6d22d7482aa6dccba5554ae497e5b0c3572357a5cead6f4822ee4428edc12207 version: "" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d95ce7a..650487d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Change log +### Version: 1.3.3 +#### Date: Oct-27-2025 + - Fix: Used common serialize method for query params + ### Version: 1.3.1 #### Date: Sept-01-2025 - Fix: Replace URLSearchParams.set() with React Native compatible implementation diff --git a/package-lock.json b/package-lock.json index 5e7329b..4a1c224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@contentstack/core", - "version": "1.3.1", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/core", - "version": "1.3.1", + "version": "1.3.3", "license": "MIT", "dependencies": { - "axios": "^1.11.0", + "axios": "^1.12.2", "axios-mock-adapter": "^2.1.0", "husky": "^9.1.7", "lodash": "^4.17.21", @@ -3226,9 +3226,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", diff --git a/package.json b/package.json index 06eea1c..11b9da4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/core", - "version": "1.3.1", + "version": "1.3.3", "type": "commonjs", "main": "./dist/cjs/src/index.js", "types": "./dist/cjs/src/index.d.ts", @@ -20,12 +20,12 @@ "husky-check": "npx husky install && chmod +x .husky/pre-commit" }, "dependencies": { - "axios": "^1.11.0", + "axios": "^1.12.2", "axios-mock-adapter": "^2.1.0", + "husky": "^9.1.7", "lodash": "^4.17.21", "qs": "^6.14.0", - "tslib": "^2.8.1", - "husky": "^9.1.7" + "tslib": "^2.8.1" }, "files": [ "dist/*", diff --git a/src/index.ts b/src/index.ts index 01bd35e..0a60201 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/contentstack-core'; export * from './lib/types'; export * from './lib/contentstack-error'; +export * from './lib/api-error'; export * from './lib/request'; export * from './lib/retryPolicy/delivery-sdk-handlers'; diff --git a/src/lib/api-error.ts b/src/lib/api-error.ts new file mode 100644 index 0000000..cc35928 --- /dev/null +++ b/src/lib/api-error.ts @@ -0,0 +1,72 @@ +/** + * Custom error class for API errors with optimized error handling + */ +export class APIError extends Error { + public error_code: number | string; + public status: number; + public error_message: string; + + constructor(message: string, error_code: number | string, status: number) { + super(message); + this.name = 'APIError'; + this.error_code = error_code; + this.status = status; + this.error_message = message; + + // Remove the stack trace completely to avoid showing internal error handling + this.stack = undefined; + } + + /** + * Creates an APIError from an Axios error response + * @param err - The Axios error object + * @returns Formatted APIError with meaningful information + */ + static fromAxiosError(err: any): APIError { + if (err.response?.data) { + return APIError.fromResponseData(err.response.data, err.response.status); + } else if (err.message) { + // For network errors or other non-HTTP errors + return new APIError(err.message, err.code || 'NETWORK_ERROR', 0); + } else { + // Fallback for unknown errors + return new APIError('Unknown error occurred', 'UNKNOWN_ERROR', 0); + } + } + + /** + * Creates an APIError from response data + * @param responseData - The response data from the API + * @param status - The HTTP status code + * @returns Formatted APIError + */ + static fromResponseData(responseData: any, status: number): APIError { + // Extract error message with fallback chain + const errorMessage = APIError.extractErrorMessage(responseData); + + // Extract error code with fallback chain + const errorCode = APIError.extractErrorCode(responseData, status); + + return new APIError(errorMessage, errorCode, status); + } + + /** + * Extracts error message from response data with multiple fallback options + */ + private static extractErrorMessage(responseData: any): string { + if (responseData.error_message) return responseData.error_message; + if (responseData.message) return responseData.message; + if (responseData.error) return responseData.error; + if (typeof responseData === 'string') return responseData; + return 'Request failed'; + } + + /** + * Extracts error code from response data with fallback to status + */ + private static extractErrorCode(responseData: any, status: number): number | string { + if (responseData.error_code) return responseData.error_code; + if (responseData.code) return responseData.code; + return status; + } +} diff --git a/src/lib/request.ts b/src/lib/request.ts index 5b9000a..13e35df 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,4 +1,6 @@ import { AxiosInstance } from './types'; +import { serialize } from './param-serializer'; +import { APIError } from './api-error'; /** * Handles array parameters properly with & separators @@ -6,20 +8,7 @@ import { AxiosInstance } from './types'; */ function serializeParams(params: any): string { if (!params) return ''; - - const parts: string[] = []; - Object.keys(params).forEach(key => { - const value = params[key]; - if (Array.isArray(value)) { - value.forEach(item => { - parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(item)}`); - }); - } else if (value !== null && value !== undefined) { - parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); - } - }); - - return parts.join('&'); + return serialize(params); } /** @@ -48,6 +37,15 @@ async function makeRequest(instance: AxiosInstance, url: string, requestConfig: } } +/** + * Handles and formats errors from Axios requests + * @param err - The error object from Axios + * @returns Formatted error object with meaningful information + */ +function handleRequestError(err: any): Error { + return APIError.fromAxiosError(err); +} + export async function getData(instance: AxiosInstance, url: string, data?: any) { try { if (instance.stackConfig && instance.stackConfig.live_preview) { @@ -87,6 +85,6 @@ export async function getData(instance: AxiosInstance, url: string, data?: any) throw response; } } catch (err: any) { - throw err; + throw handleRequestError(err); } } diff --git a/src/lib/retryPolicy/delivery-sdk-handlers.ts b/src/lib/retryPolicy/delivery-sdk-handlers.ts index d489d23..9793cf6 100644 --- a/src/lib/retryPolicy/delivery-sdk-handlers.ts +++ b/src/lib/retryPolicy/delivery-sdk-handlers.ts @@ -83,7 +83,17 @@ export const retryResponseErrorHandler = (error: any, config: any, axiosInstance } error.config.retryCount = retryCount; - return axiosInstance(error.config); + // Apply configured delay for retries + return new Promise((resolve, reject) => { + setTimeout(async () => { + try { + const retryResponse = await axiosInstance(error.config); + resolve(retryResponse); + } catch (retryError) { + reject(retryError); + } + }, config.retryDelay || 300); // Use configured delay with fallback + }); } } @@ -99,17 +109,22 @@ export const retryResponseErrorHandler = (error: any, config: any, axiosInstance } }; const retry = (error: any, config: any, retryCount: number, retryDelay: number, axiosInstance: AxiosInstance) => { - let delayTime: number = retryDelay; if (retryCount > config.retryLimit) { return Promise.reject(error); } - delayTime = config.retryDelay; + // Use the passed retryDelay parameter first, then config.retryDelay, then default + const delayTime = retryDelay || config.retryDelay || 300; error.config.retryCount = retryCount; - return new Promise(function (resolve) { - return setTimeout(function () { - return resolve(axiosInstance(error.request)); + return new Promise(function (resolve, reject) { + return setTimeout(async function () { + try { + const retryResponse = await axiosInstance(error.config); + resolve(retryResponse); + } catch (retryError) { + reject(retryError); + } }, delayTime); }); }; diff --git a/test/contentstack-core.spec.ts b/test/contentstack-core.spec.ts index ab808f6..3767561 100644 --- a/test/contentstack-core.spec.ts +++ b/test/contentstack-core.spec.ts @@ -152,6 +152,7 @@ describe('contentstackCore', () => { it('should call the onError function when an error occurs', async () => { const onError = jest.fn(); const options = { + defaultHostname: 'cdn.contentstack.io', onError, }; diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index 9ee1096..d0e8599 100644 --- a/test/retryPolicy/delivery-sdk-handlers.spec.ts +++ b/test/retryPolicy/delivery-sdk-handlers.spec.ts @@ -154,7 +154,14 @@ describe('retryResponseErrorHandler', () => { }); it('should call the retry function if retryCondition is passed', async () => { const error = { - config: { retryOnError: true, retryCount: 4 }, + config: { + retryOnError: true, + retryCount: 4, + method: 'post', + url: '/retryURL', + data: { key: 'value' }, + headers: { 'Content-Type': 'application/json' } + }, response: { status: 200, statusText: 'Success Response but retry needed', @@ -231,6 +238,103 @@ describe('retryResponseErrorHandler', () => { expect(response.status).toBe(200); }); + it('should use configured retryDelay for 429 status retries', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + statusText: 'Rate limit exceeded', + headers: {}, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3, retryDelay: 500 }; + const client = axios.create(); + + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + const startTime = Date.now(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by the configured delay + jest.advanceTimersByTime(500); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + + jest.useRealTimers(); + }); + + it('should use configured retryDelay for 401 status retries', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 401, + statusText: 'Unauthorized', + headers: {}, + data: { + error_message: 'Unauthorized', + error_code: 401, + errors: null, + }, + }, + }; + const config = { retryLimit: 3, retryDelay: 250 }; + const client = axios.create(); + + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by the configured delay + jest.advanceTimersByTime(250); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + + jest.useRealTimers(); + }); + + it('should use default retryDelay (300ms) when not configured for 429 retries', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + statusText: 'Rate limit exceeded', + headers: {}, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; // No retryDelay specified + const client = axios.create(); + + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by the default delay (300ms) + jest.advanceTimersByTime(300); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + + jest.useRealTimers(); + }); + it('should retry when retryCondition is true', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, @@ -256,6 +360,40 @@ describe('retryResponseErrorHandler', () => { expect(retryCondition).toHaveBeenCalledWith(error); }); + it('should use configured retryDelay when retryCondition triggers retry', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 500, + statusText: 'Internal Server Error', + headers: {}, + data: { + error_message: 'Internal Server Error', + error_code: 500, + errors: null, + }, + }, + }; + const retryCondition = jest.fn().mockReturnValue(true); + const config = { retryLimit: 3, retryCondition, retryDelay: 750 }; + const client = axios.create(); + + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by the configured delay + jest.advanceTimersByTime(750); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + expect(retryCondition).toHaveBeenCalledWith(error); + + jest.useRealTimers(); + }); + it('should retry with delay when x-ratelimit-remaining is 0 and retry-after header is present', async () => { const error = { config: { retryOnError: true, retryCount: 1 },