diff --git a/.talismanrc b/.talismanrc index f8a8fc6..cc665b3 100644 --- a/.talismanrc +++ b/.talismanrc @@ -6,4 +6,6 @@ fileignoreconfig: checksum: 34d28e7736ffac2b27d3708b6bca28591f3a930292433001d2397bfdf2d2fd0f - filename: .husky/pre-commit checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193 +- filename: test/request.spec.ts + checksum: 87afd3bb570fd52437404cbe69a39311ad8a8c73bca9d075ecf88652fd3e13f6 version: "" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bd89823..d577dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## Change log +### Version: 1.2.4 +#### Date: Aug-18-2025 + - Fix: Retry request logic after rate limit replenishes + ### Version: 1.2.3 #### Date: Aug-04-2025 - Fix: Added Pre-commit hook to run talisman and snyk scan diff --git a/package.json b/package.json index 6718679..8b8bb9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/core", - "version": "1.2.3", + "version": "1.2.4", "type": "commonjs", "main": "./dist/cjs/src/index.js", "types": "./dist/cjs/src/index.d.ts", diff --git a/src/lib/retryPolicy/delivery-sdk-handlers.ts b/src/lib/retryPolicy/delivery-sdk-handlers.ts index 7abb95c..ae4f5b9 100644 --- a/src/lib/retryPolicy/delivery-sdk-handlers.ts +++ b/src/lib/retryPolicy/delivery-sdk-handlers.ts @@ -45,8 +45,30 @@ export const retryResponseErrorHandler = (error: any, config: any, axiosInstance } } else { const rateLimitRemaining = response.headers['x-ratelimit-remaining']; + + // Handle rate limit exhaustion with retry logic if (rateLimitRemaining !== undefined && parseInt(rateLimitRemaining) <= 0) { - return Promise.reject(error.response.data); + retryCount++; + + if (retryCount >= config.retryLimit) { + return Promise.reject(error.response.data); + } + + error.config.retryCount = retryCount; + + // Calculate delay for rate limit reset + const rateLimitResetDelay = calculateRateLimitDelay(response.headers); + + return new Promise((resolve, reject) => { + setTimeout(async () => { + try { + const retryResponse = await axiosInstance(error.config); + resolve(retryResponse); + } catch (retryError) { + reject(retryError); + } + }, rateLimitResetDelay); + }); } if (response.status == 429 || response.status == 401) { @@ -99,3 +121,41 @@ const retry = (error: any, config: any, retryCount: number, retryDelay: number, }, delayTime); }); }; + +/** + * Calculate delay time for rate limit reset based on response headers + * @param headers - Response headers from the API + * @returns Delay time in milliseconds + */ +export const calculateRateLimitDelay = (headers: any): number => { + // Check for retry-after header (in seconds) + const retryAfter = headers['retry-after']; + if (retryAfter) { + return parseInt(retryAfter) * 1000; // Convert to milliseconds + } + + // Check for x-ratelimit-reset header (Unix timestamp) + const rateLimitReset = headers['x-ratelimit-reset']; + if (rateLimitReset) { + const resetTime = parseInt(rateLimitReset) * 1000; // Convert to milliseconds + const currentTime = Date.now(); + const delay = resetTime - currentTime; + + // Ensure we have a positive delay, add a small buffer + return Math.max(delay + 1000, 1000); // At least 1 second delay + } + + // Check for x-ratelimit-reset-time header (ISO string) + const rateLimitResetTime = headers['x-ratelimit-reset-time']; + if (rateLimitResetTime) { + const resetTime = new Date(rateLimitResetTime).getTime(); + const currentTime = Date.now(); + const delay = resetTime - currentTime; + + // Ensure we have a positive delay, add a small buffer + return Math.max(delay + 1000, 1000); // At least 1 second delay + } + + // Default fallback delay (1 second) if no rate limit reset info is available + return 1000; +}; diff --git a/test/request.spec.ts b/test/request.spec.ts index 5df7ff0..bd746e7 100644 --- a/test/request.spec.ts +++ b/test/request.spec.ts @@ -111,4 +111,213 @@ describe('Request tests', () => { expect(mock.history.get[0].url).toBe(livePreviewURL); expect(result).toEqual(mockResponse); }); + + it('should throw error when response has no data property', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const responseWithoutData = { status: 200, headers: {} }; // Response without data property + + // Mock response that returns undefined/empty data + mock.onGet(url).reply(() => [200, undefined, {}]); + + await expect(getData(client, url)).rejects.toThrowError(); + }); + + it('should throw error when response is null', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + + // Mock response that returns null + mock.onGet(url).reply(() => [200, null]); + + await expect(getData(client, url)).rejects.toThrowError(); + }); + + it('should handle live_preview when enable is false', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + + client.stackConfig = { + live_preview: { + enable: false, // Disabled + preview_token: 'someToken', + live_preview: 'someHash', + host: 'rest-preview.com', + }, + }; + + mock.onGet(url).reply(200, mockResponse); + + const result = await getData(client, url, {}); + + // Should not modify URL when live preview is disabled + expect(mock.history.get[0].url).toBe(url); + expect(result).toEqual(mockResponse); + }); + + it('should handle request when stackConfig is undefined', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + + // No stackConfig set + client.stackConfig = undefined; + + mock.onGet(url).reply(200, mockResponse); + + const result = await getData(client, url, {}); + expect(result).toEqual(mockResponse); + }); + + it('should handle request when stackConfig exists but live_preview is undefined', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + + client.stackConfig = { + // live_preview not defined + apiKey: 'test-key', + }; + + mock.onGet(url).reply(200, mockResponse); + + const result = await getData(client, url, {}); + expect(result).toEqual(mockResponse); + }); + + it('should set live_preview to "init" when enable is true and no live_preview provided', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + + client.stackConfig = { + live_preview: { + enable: true, + preview_token: 'someToken', + // live_preview not provided + }, + }; + + mock.onGet(url).reply(200, mockResponse); + + const data: any = {}; + const result = await getData(client, url, data); + + // Should set live_preview to 'init' + expect(data.live_preview).toBe('init'); + expect(result).toEqual(mockResponse); + }); + + it('should set headers when preview_token is provided', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + + client.stackConfig = { + live_preview: { + enable: true, + preview_token: 'test-preview-token', + live_preview: 'init', + }, + }; + + mock.onGet(url).reply(200, mockResponse); + + const result = await getData(client, url, {}); + + // Should set headers + expect(client.defaults.headers.preview_token).toBe('test-preview-token'); + expect(client.defaults.headers.live_preview).toBe('init'); + expect(result).toEqual(mockResponse); + }); + + it('should handle live_preview when enable is true but no preview_token', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + + client.stackConfig = { + live_preview: { + enable: true, + live_preview: 'init', + // preview_token not provided + }, + }; + + mock.onGet(url).reply(200, mockResponse); + + const data: any = {}; + const result = await getData(client, url, data); + + // Should still set live_preview in data + expect(data.live_preview).toBe('init'); + expect(result).toEqual(mockResponse); + }); + + it('should handle custom error messages when request fails', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const customError = new Error('Custom network error'); + + mock.onGet(url).reply(() => { + throw customError; + }); + + await expect(getData(client, url)).rejects.toThrowError('Custom network error'); + }); + + it('should handle non-Error objects as errors when they have message property', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const errorObject = { status: 500, message: 'Internal Server Error' }; + + mock.onGet(url).reply(() => { + throw errorObject; + }); + + // When error has message property, it uses the message + await expect(getData(client, url)).rejects.toThrowError('Internal Server Error'); + }); + + it('should handle non-Error objects as errors when they have no message property', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const errorObject = { status: 500, code: 'SERVER_ERROR' }; + + mock.onGet(url).reply(() => { + throw errorObject; + }); + + // When error has no message property, it stringifies the object + await expect(getData(client, url)).rejects.toThrowError(JSON.stringify(errorObject)); + }); + + it('should pass data parameter to axios get request', async () => { + const client = httpClient({}); + const mock = new MockAdapter(client as any); + const url = '/your-api-endpoint'; + const mockResponse = { data: 'mocked' }; + const requestData = { params: { limit: 10, skip: 0 } }; + + mock.onGet(url).reply((config) => { + // Verify that data was passed correctly + expect(config.params).toEqual(requestData.params); + return [200, mockResponse]; + }); + + const result = await getData(client, url, requestData); + expect(result).toEqual(mockResponse); + }); }); diff --git a/test/retryPolicy/delivery-sdk-handlers.spec.ts b/test/retryPolicy/delivery-sdk-handlers.spec.ts index f6cd93a..15a1d83 100644 --- a/test/retryPolicy/delivery-sdk-handlers.spec.ts +++ b/test/retryPolicy/delivery-sdk-handlers.spec.ts @@ -4,6 +4,7 @@ import { retryRequestHandler, retryResponseHandler, retryResponseErrorHandler, + calculateRateLimitDelay, } from '../../src/lib/retryPolicy/delivery-sdk-handlers'; import MockAdapter from 'axios-mock-adapter'; @@ -32,10 +33,14 @@ describe('retryResponseHandler', () => { }); describe('retryResponseErrorHandler', () => { - const mock = new MockAdapter(axios); + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); afterEach(() => { - mock.reset(); + mock.restore(); }); it('should reject the promise if retryOnError is false', async () => { const error = { config: { retryOnError: false }, code: 'ECONNABORTED' }; @@ -251,9 +256,120 @@ describe('retryResponseErrorHandler', () => { expect(retryCondition).toHaveBeenCalledWith(error); }); - it('should reject with rate limit error when x-ratelimit-remaining is 0', async () => { + 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 }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + 'retry-after': '1', // 1 second for faster testing + }, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + // Mock successful response after retry + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by 1 second + jest.advanceTimersByTime(1000); + + const response: any = await responsePromise; + + expect(response.status).toBe(200); + expect(response.data.success).toBe(true); + + jest.useRealTimers(); + }); + + it('should retry with delay when x-ratelimit-remaining is 0 and x-ratelimit-reset header is present', async () => { const error = { config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': Math.floor((Date.now() + 2000) / 1000).toString(), // 2 seconds from now + }, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + // Mock successful response after retry + mock.onAny().reply(200, { success: true }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by 3 seconds (2 + 1 buffer) + jest.advanceTimersByTime(3000); + + const response: any = await responsePromise; + + expect(response.status).toBe(200); + expect(response.data.success).toBe(true); + + jest.useRealTimers(); + }); + + it('should retry with default delay when x-ratelimit-remaining is 0 and no reset headers are present', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + }, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + // Mock successful response after retry + mock.onAny().reply(200, { success: true }); + + // Use fake timers to avoid waiting for 60 seconds + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time by 60 seconds + jest.advanceTimersByTime(60000); + + const response: any = await responsePromise; + + expect(response.status).toBe(200); + expect(response.data.success).toBe(true); + + jest.useRealTimers(); + }); + + it('should reject with rate limit error when x-ratelimit-remaining is 0 and retry limit is exceeded', async () => { + const error = { + config: { retryOnError: true, retryCount: 3 }, // Already at retry limit response: { status: 429, headers: { @@ -307,4 +423,426 @@ describe('retryResponseErrorHandler', () => { const response: any = await retryResponseErrorHandler(error, config, client); expect(response.status).toBe(200); }); + + it('should successfully retry after rate limit token replenishment using x-ratelimit-reset-time header', async () => { + const futureResetTime = new Date(Date.now() + 1500); // 1.5 seconds from now + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset-time': futureResetTime.toISOString(), + }, + data: { + error_message: 'Rate limit exceeded', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + // Mock successful response after retry (simulating token replenishment) + mock.onAny().reply(200, { + success: true, + message: 'Request successful after token replenishment', + data: { id: 123, name: 'test-content' }, + }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time to simulate waiting for token replenishment (1.5s + 1s buffer = 2.5s) + jest.advanceTimersByTime(2500); + + const response: any = await responsePromise; + + expect(response.status).toBe(200); + expect(response.data.success).toBe(true); + expect(response.data.message).toBe('Request successful after token replenishment'); + expect(response.data.data.id).toBe(123); + + jest.useRealTimers(); + }); + + it('should handle token replenishment scenario with increasing delay', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + 'retry-after': '2', // 2 seconds for token replenishment + }, + data: { + error_message: 'Rate limit exceeded - tokens exhausted', + error_code: 429, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + // Mock successful response after token replenishment delay + mock.onAny().reply(200, { + success: true, + message: 'Tokens successfully replenished', + tokensRemaining: 10, + timestamp: Date.now(), + }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time to simulate waiting for token replenishment + jest.advanceTimersByTime(2000); // 2 seconds as specified in retry-after + + const response: any = await responsePromise; + + expect(response.status).toBe(200); + expect(response.data.success).toBe(true); + expect(response.data.message).toBe('Tokens successfully replenished'); + expect(response.data.tokensRemaining).toBe(10); + + jest.useRealTimers(); + }); + + it('should simulate real-world token bucket replenishment after rate limit exhaustion', async () => { + const currentTime = Date.now(); + const resetTime = currentTime + 3000; // 3 seconds from now + + const error = { + config: { + retryOnError: true, + retryCount: 1, + url: '/v3/content_types/blog_post/entries', + method: 'GET', + }, + response: { + status: 429, + headers: { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': Math.floor(resetTime / 1000).toString(), // Unix timestamp + 'x-ratelimit-limit': '100', + }, + data: { + error_message: 'API rate limit exceeded. Try again after some time.', + error_code: 429, + errors: { + rate_limit: ['Too Many Requests - Rate limit quota exceeded'], + }, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + // Mock successful response after token bucket is replenished + mock.onGet('/v3/content_types/blog_post/entries').reply(200, { + entries: [ + { uid: 'entry1', title: 'Blog Post 1', content: 'Content 1' }, + { uid: 'entry2', title: 'Blog Post 2', content: 'Content 2' }, + ], + total_count: 2, + rate_limit_info: { + remaining: 99, + limit: 100, + reset_time: Math.floor((currentTime + 60000) / 1000), // Next reset in 1 minute + }, + }); + + jest.useFakeTimers(); + jest.setSystemTime(currentTime); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time to simulate waiting for token bucket replenishment (3s + 1s buffer) + jest.advanceTimersByTime(4000); + + const response: any = await responsePromise; + + expect(response.status).toBe(200); + expect(response.data.entries).toHaveLength(2); + expect(response.data.rate_limit_info.remaining).toBe(99); + expect(response.data.rate_limit_info.limit).toBe(100); + expect(response.data.total_count).toBe(2); + + jest.useRealTimers(); + }); + + it('should handle retry error rejection when rate limited request fails on retry', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + 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 retry to fail with a different error + mock.onAny().reply(() => { + throw new Error('Network timeout during retry'); + }); + + jest.useFakeTimers(); + + const responsePromise = retryResponseErrorHandler(error, config, client); + + // Fast-forward time to trigger the retry + jest.advanceTimersByTime(1000); + + await expect(responsePromise).rejects.toThrow('Network timeout during retry'); + + jest.useRealTimers(); + }); + + it('should reject with original error when 429/401 response has no data', async () => { + const error = { + config: { retryOnError: true, retryCount: 5 }, + response: { + status: 429, + statusText: 'Rate limit exceeded', + headers: {}, + // No data property + }, + }; + const config = { retryLimit: 5 }; + const client = axios.create(); + + await expect(retryResponseErrorHandler(error, config, client)).rejects.toEqual(error); + }); + + it('should create and throw custom error for non-retryable responses', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 400, + statusText: 'Bad Request', + headers: {}, + data: { + error_message: 'Invalid request parameters', + error_code: 400, + errors: ['Missing required field: title'], + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + try { + await retryResponseErrorHandler(error, config, client); + fail('Expected retryResponseErrorHandler to throw a custom error'); + } catch (customError: any) { + expect(customError.status).toBe(400); + expect(customError.statusText).toBe('Bad Request'); + expect(customError.error_message).toBe('Invalid request parameters'); + expect(customError.error_code).toBe(400); + expect(customError.errors).toEqual(['Missing required field: title']); + } + }); + + it('should create custom error for 500 internal server error', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 500, + statusText: 'Internal Server Error', + headers: {}, + data: { + error_message: 'Database connection failed', + error_code: 500, + errors: null, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + try { + await retryResponseErrorHandler(error, config, client); + fail('Expected retryResponseErrorHandler to throw a custom error'); + } catch (customError: any) { + expect(customError.status).toBe(500); + expect(customError.statusText).toBe('Internal Server Error'); + expect(customError.error_message).toBe('Database connection failed'); + expect(customError.error_code).toBe(500); + expect(customError.errors).toBe(null); + } + }); + + it('should handle custom error for 422 unprocessable entity', async () => { + const error = { + config: { retryOnError: true, retryCount: 1 }, + response: { + status: 422, + statusText: 'Unprocessable Entity', + headers: {}, + data: { + error_message: 'Validation failed', + error_code: 422, + errors: { + title: ['Title is required'], + content: ['Content cannot be empty'], + }, + }, + }, + }; + const config = { retryLimit: 3 }; + const client = axios.create(); + + try { + await retryResponseErrorHandler(error, config, client); + fail('Expected retryResponseErrorHandler to throw a custom error'); + } catch (customError: any) { + expect(customError.status).toBe(422); + expect(customError.statusText).toBe('Unprocessable Entity'); + expect(customError.error_message).toBe('Validation failed'); + expect(customError.error_code).toBe(422); + expect(customError.errors).toEqual({ + title: ['Title is required'], + content: ['Content cannot be empty'], + }); + } + }); +}); + +describe('calculateRateLimitDelay', () => { + it('should return delay from retry-after header in milliseconds', () => { + const headers = { 'retry-after': '5' }; + const delay = calculateRateLimitDelay(headers); + expect(delay).toBe(5000); // 5 seconds * 1000 = 5000ms + }); + + it('should return delay from x-ratelimit-reset header with Unix timestamp', () => { + const currentTime = Date.now(); + const resetTime = Math.floor((currentTime + 3000) / 1000); // 3 seconds from now + + jest.spyOn(Date, 'now').mockReturnValue(currentTime); + + const headers = { 'x-ratelimit-reset': resetTime.toString() }; + const delay = calculateRateLimitDelay(headers); + + // Should be approximately 3000ms + 1000ms buffer, allowing for some timing variance + expect(delay).toBeGreaterThanOrEqual(3000); + expect(delay).toBeLessThan(5000); + + jest.restoreAllMocks(); + }); + + it('should return minimum delay when x-ratelimit-reset is in the past', () => { + const currentTime = Date.now(); + const pastTime = Math.floor((currentTime - 5000) / 1000); // 5 seconds ago + + jest.spyOn(Date, 'now').mockReturnValue(currentTime); + + const headers = { 'x-ratelimit-reset': pastTime.toString() }; + const delay = calculateRateLimitDelay(headers); + + // Should return minimum delay of 1000ms + expect(delay).toBe(1000); + + jest.restoreAllMocks(); + }); + + it('should return delay from x-ratelimit-reset-time header with ISO string', () => { + const currentTime = Date.now(); + const futureTime = new Date(currentTime + 2500); // 2.5 seconds from now + + jest.spyOn(Date, 'now').mockReturnValue(currentTime); + + const headers = { 'x-ratelimit-reset-time': futureTime.toISOString() }; + const delay = calculateRateLimitDelay(headers); + + // Should be 2500ms + 1000ms buffer = 3500ms minimum + expect(delay).toBeGreaterThanOrEqual(3500); + expect(delay).toBeLessThan(4000); + + jest.restoreAllMocks(); + }); + + it('should return minimum delay when x-ratelimit-reset-time is in the past', () => { + const currentTime = Date.now(); + const pastTime = new Date(currentTime - 3000); // 3 seconds ago + + jest.spyOn(Date, 'now').mockReturnValue(currentTime); + + const headers = { 'x-ratelimit-reset-time': pastTime.toISOString() }; + const delay = calculateRateLimitDelay(headers); + + // Should return minimum delay of 1000ms + expect(delay).toBe(1000); + + jest.restoreAllMocks(); + }); + + it('should return default fallback delay when no rate limit headers are present', () => { + const headers = {}; // No rate limit headers + const delay = calculateRateLimitDelay(headers); + + // Should return default fallback of 1 second + expect(delay).toBe(1000); + }); + + it('should return default fallback delay when headers have other unrelated values', () => { + const headers = { + 'content-type': 'application/json', + 'x-api-version': '3.0', + 'cache-control': 'no-cache', + }; + const delay = calculateRateLimitDelay(headers); + + // Should return default fallback of 1 second + expect(delay).toBe(1000); + }); + + it('should prioritize retry-after over other headers', () => { + const currentTime = Date.now(); + const futureResetTime = Math.floor((currentTime + 10000) / 1000); // 10 seconds from now + + const headers = { + 'retry-after': '2', // 2 seconds + 'x-ratelimit-reset': futureResetTime.toString(), // 10 seconds + 'x-ratelimit-reset-time': new Date(currentTime + 15000).toISOString(), // 15 seconds + }; + + const delay = calculateRateLimitDelay(headers); + + // Should use retry-after (2 seconds = 2000ms) + expect(delay).toBe(2000); + }); + + it('should prioritize x-ratelimit-reset over x-ratelimit-reset-time when retry-after is not present', () => { + const currentTime = Date.now(); + const resetTime = Math.floor((currentTime + 5000) / 1000); // 5 seconds from now + + jest.spyOn(Date, 'now').mockReturnValue(currentTime); + + const headers = { + 'x-ratelimit-reset': resetTime.toString(), // 5 seconds + 'x-ratelimit-reset-time': new Date(currentTime + 8000).toISOString(), // 8 seconds + }; + + const delay = calculateRateLimitDelay(headers); + + // Should use x-ratelimit-reset (approximately 5 seconds + 1 second buffer), allowing for timing variance + expect(delay).toBeGreaterThanOrEqual(5000); + expect(delay).toBeLessThan(7000); + + jest.restoreAllMocks(); + }); });