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/contentstack-core.ts b/src/lib/contentstack-core.ts index 7197ea1..d1f18ef 100644 --- a/src/lib/contentstack-core.ts +++ b/src/lib/contentstack-core.ts @@ -14,13 +14,16 @@ export function httpClient(options: HttpClientParams): AxiosInstance { httpsAgent: false, timeout: 30000, logHandler: (level: string, data?: any) => { - if (level === 'error' && data) { - const title = [data.name, data.message].filter((a) => a).join(' - '); - console.error(`[error] ${title}`); - + if (level === 'error') { + if (data) { + const title = [data.name, data.message].filter((a) => a).join(' - '); + console.error(`[error] ${title}`); + } return; } - console.log(`[${level}] ${data}`); + if (data !== undefined) { + console.log(`[${level}] ${data}`); + } }, retryCondition: (error: any) => { if (error.response && error.response.status === 429) { 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/api-error.spec.ts b/test/api-error.spec.ts new file mode 100644 index 0000000..5c765a6 --- /dev/null +++ b/test/api-error.spec.ts @@ -0,0 +1,209 @@ +import { APIError } from '../src/lib/api-error'; + +describe('APIError', () => { + describe('constructor', () => { + it('should create an APIError with all properties', () => { + const error = new APIError('Test error', 'ERROR_CODE', 404); + + expect(error.message).toBe('Test error'); + expect(error.error_code).toBe('ERROR_CODE'); + expect(error.status).toBe(404); + expect(error.error_message).toBe('Test error'); + expect(error.name).toBe('APIError'); + expect(error.stack).toBeUndefined(); + }); + + it('should create an APIError with numeric error_code', () => { + const error = new APIError('Test error', 500, 500); + + expect(error.error_code).toBe(500); + expect(error.status).toBe(500); + }); + }); + + describe('fromAxiosError', () => { + it('should create APIError from axios error with response data', () => { + const axiosError = { + response: { + data: { + error_message: 'Not Found', + error_code: 404, + }, + status: 404, + }, + }; + + const error = APIError.fromAxiosError(axiosError); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Not Found'); + expect(error.error_code).toBe(404); + expect(error.status).toBe(404); + }); + + it('should create APIError from axios error with message but no response', () => { + const axiosError = { + message: 'Network Error', + code: 'ENOTFOUND', + }; + + const error = APIError.fromAxiosError(axiosError); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Network Error'); + expect(error.error_code).toBe('ENOTFOUND'); + expect(error.status).toBe(0); + }); + + it('should create APIError from axios error with message but no code', () => { + const axiosError = { + message: 'Network Error', + }; + + const error = APIError.fromAxiosError(axiosError); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Network Error'); + expect(error.error_code).toBe('NETWORK_ERROR'); + expect(error.status).toBe(0); + }); + + it('should create APIError with default message for unknown errors', () => { + const axiosError = {}; + + const error = APIError.fromAxiosError(axiosError); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Unknown error occurred'); + expect(error.error_code).toBe('UNKNOWN_ERROR'); + expect(error.status).toBe(0); + }); + + it('should handle axios error with response.data but no response.status', () => { + const axiosError = { + response: { + data: { + error_message: 'Server Error', + }, + }, + }; + + // This should call fromResponseData, which requires status + // Let's test with a proper status + const axiosErrorWithStatus = { + response: { + data: { + error_message: 'Server Error', + }, + status: 500, + }, + }; + + const error = APIError.fromAxiosError(axiosErrorWithStatus); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Server Error'); + expect(error.status).toBe(500); + }); + }); + + describe('fromResponseData', () => { + it('should create APIError from response data with error_message', () => { + const responseData = { + error_message: 'Bad Request', + error_code: 400, + }; + + const error = APIError.fromResponseData(responseData, 400); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Bad Request'); + expect(error.error_code).toBe(400); + expect(error.status).toBe(400); + }); + + it('should create APIError from response data with message fallback', () => { + const responseData = { + message: 'Internal Server Error', + code: 500, + }; + + const error = APIError.fromResponseData(responseData, 500); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Internal Server Error'); + expect(error.error_code).toBe(500); + expect(error.status).toBe(500); + }); + + it('should create APIError from response data with error fallback', () => { + const responseData = { + error: 'Validation Error', + code: 422, + }; + + const error = APIError.fromResponseData(responseData, 422); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Validation Error'); + expect(error.error_code).toBe(422); + expect(error.status).toBe(422); + }); + + it('should create APIError from string response data', () => { + const responseData = 'Plain text error message'; + + const error = APIError.fromResponseData(responseData, 500); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Plain text error message'); + expect(error.error_code).toBe(500); + expect(error.status).toBe(500); + }); + + it('should create APIError with default message when no error fields present', () => { + const responseData = { + someOtherField: 'value', + }; + + const error = APIError.fromResponseData(responseData, 500); + + expect(error).toBeInstanceOf(APIError); + expect(error.error_message).toBe('Request failed'); + expect(error.error_code).toBe(500); + expect(error.status).toBe(500); + }); + + it('should extract error_code from response data', () => { + const responseData = { + error_message: 'Error', + error_code: 999, + }; + + const error = APIError.fromResponseData(responseData, 500); + + expect(error.error_code).toBe(999); + }); + + it('should extract code from response data when error_code not present', () => { + const responseData = { + error_message: 'Error', + code: 888, + }; + + const error = APIError.fromResponseData(responseData, 500); + + expect(error.error_code).toBe(888); + }); + + it('should use status as error_code fallback', () => { + const responseData = { + error_message: 'Error', + }; + + const error = APIError.fromResponseData(responseData, 503); + + expect(error.error_code).toBe(503); + }); + }); +}); diff --git a/test/contentstack-core.spec.ts b/test/contentstack-core.spec.ts index ab808f6..1cfd3ab 100644 --- a/test/contentstack-core.spec.ts +++ b/test/contentstack-core.spec.ts @@ -150,8 +150,12 @@ describe('contentstackCore', () => { describe('config.onError', () => { it('should call the onError function when an error occurs', async () => { + // Suppress expected console.error from network error + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const onError = jest.fn(); const options = { + defaultHostname: 'cdn.contentstack.io', onError, }; @@ -162,6 +166,8 @@ describe('contentstackCore', () => { } catch (error: unknown) { expect(onError).toBeCalledWith(error); } + + consoleErrorSpy.mockRestore(); }); it('should not call the onError function when no error occurs', async () => { diff --git a/test/request.spec.ts b/test/request.spec.ts index 40a9ec2..1214497 100644 --- a/test/request.spec.ts +++ b/test/request.spec.ts @@ -344,4 +344,34 @@ describe('Request tests', () => { const result = await getData(client, url, requestData); expect(result).toEqual(mockResponse); }); + + it('should use instance.request when URL length exceeds 2000 characters', async () => { + const client = httpClient({ defaultHostname: 'example.com' }); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + + // Create a very long query parameter that will exceed 2000 characters when combined with baseURL + // baseURL is typically like 'https://example.com:443/v3' (~30 chars), url is '/your-api-endpoint' (~20 chars) + // So we need params that serialize to >1950 chars to exceed 2000 total + const longParam = 'x'.repeat(2000); + const requestData = { params: { longParam, param2: 'y'.repeat(500) } }; + + // Mock instance.request since that's what gets called for long URLs + const requestSpy = jest.spyOn(client, 'request').mockResolvedValue({ data: mockResponse } as any); + + const result = await getData(client, url, requestData); + + expect(result).toEqual(mockResponse); + // Verify that request was called (not get) with the full URL + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + url: expect.stringMatching(/longParam/), + maxContentLength: Infinity, + maxBodyLength: Infinity, + }) + ); + + requestSpy.mockRestore(); + }); }); diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index 9ee1096..659cfe8 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,232 @@ 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 use default retryDelay (300ms) when retryDelay is explicitly undefined', 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: undefined }; // Explicitly undefined + 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) - fallback from undefined + jest.advanceTimersByTime(300); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + + jest.useRealTimers(); + }); + + it('should use default retryDelay (300ms) when retryDelay is null', 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: null as any }; // null retryDelay + 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) - fallback from null + jest.advanceTimersByTime(300); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + + jest.useRealTimers(); + }); + + it('should use default delay in retry function when retryDelay is 0', async () => { + const error = { + config: { retryOnError: true, retryCount: 1, method: 'get', url: '/test' }, + response: { + status: 500, + headers: {}, + data: { + error_message: 'Server Error', + error_code: 500, + errors: null, + }, + }, + }; + const retryCondition = jest.fn().mockReturnValue(true); + const config = { retryLimit: 3, retryCondition, retryDelay: 0 }; // 0 is falsy, should use fallback + const client = axios.create(); + + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by default delay (300ms) - 0 is falsy, should use fallback + jest.advanceTimersByTime(300); + + const response = (await responsePromise) as AxiosResponse; + expect(response.status).toBe(200); + expect(retryCondition).toHaveBeenCalledWith(error); + + jest.useRealTimers(); + }); + + it('should use config.retryDelay when retryDelay parameter is 0 in retry function', async () => { + const error = { + config: { retryOnError: true, retryCount: 1, method: 'get', url: '/test' }, + response: { + status: 500, + headers: {}, + data: { + error_message: 'Server Error', + error_code: 500, + errors: null, + }, + }, + }; + const retryCondition = jest.fn().mockReturnValue(true); + const config = { retryLimit: 3, retryCondition, retryDelay: 500 }; // config has retryDelay + const client = axios.create(); + + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by config.retryDelay (500ms) + jest.advanceTimersByTime(500); + + 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 +489,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 }, @@ -611,6 +878,103 @@ describe('retryResponseErrorHandler', () => { jest.useRealTimers(); }); + it('should reject promise when retry fails for 429 status with retry-after header', async () => { + const error = { + config: { retryOnError: true, retryCount: 1, method: 'get', url: '/test' }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + 'retry-after': '1', + }, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + // Mock the retry request to fail + mock.onGet('/test').reply(() => { + throw new Error('Retry failed'); + }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + jest.advanceTimersByTime(1000); + + await expect(responsePromise).rejects.toThrow('Retry failed'); + + jest.useRealTimers(); + }); + + it('should reject promise when retry fails for 401/429 status with retryDelay', async () => { + const error = { + config: { retryOnError: true, retryCount: 1, method: 'get', url: '/test' }, + response: { + status: 401, + headers: {}, + data: { + error_message: 'Unauthorized', + error_code: 401, + errors: null, + }, + }, + }; + const config = { retryLimit: 3, retryDelay: 100 }; + const client = axios.create(); + + // Mock the retry request to fail + mock.onGet('/test').reply(() => { + throw new Error('Retry authentication failed'); + }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + jest.advanceTimersByTime(100); + + await expect(responsePromise).rejects.toThrow('Retry authentication failed'); + + jest.useRealTimers(); + }); + + it('should reject promise when retry fails in retryCondition path', async () => { + const error = { + config: { retryOnError: true, retryCount: 1, method: 'get', url: '/test' }, + response: { + status: 500, + headers: {}, + data: { + error_message: 'Server Error', + error_code: 500, + errors: null, + }, + }, + }; + const retryCondition = jest.fn().mockReturnValue(true); + const config = { retryLimit: 3, retryCondition, retryDelay: 100 }; + const client = axios.create(); + + // Mock the retry request to fail + mock.onGet('/test').reply(() => { + throw new Error('Retry server error failed'); + }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + jest.advanceTimersByTime(100); + + await expect(responsePromise).rejects.toThrow('Retry server error failed'); + + jest.useRealTimers(); + }); + it('should reject with original error when 429/401 response has no data', async () => { const error = { config: { retryOnError: true, retryCount: 5 },