diff --git a/package.json b/package.json index bab0fd23..47efcf09 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@internxt/sdk", "author": "Internxt ", - "version": "1.14.2", + "version": "1.15.0", "description": "An sdk for interacting with Internxt's services", "repository": { "type": "git", diff --git a/src/auth/index.ts b/src/auth/index.ts index 5855a47e..3a6d7caa 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -33,7 +33,7 @@ export class Auth { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity?: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity?.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity?.unauthorizedCallback, apiSecurity?.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; this.apiUrl = apiUrl; diff --git a/src/drive/backups/index.ts b/src/drive/backups/index.ts index d0023067..d97087ee 100644 --- a/src/drive/backups/index.ts +++ b/src/drive/backups/index.ts @@ -14,7 +14,7 @@ export class Backups { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/drive/payments/index.ts b/src/drive/payments/index.ts index a62209ca..8f68b9c8 100644 --- a/src/drive/payments/index.ts +++ b/src/drive/payments/index.ts @@ -31,7 +31,7 @@ export class Payments { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/drive/referrals/index.ts b/src/drive/referrals/index.ts index 86713088..8c997c42 100644 --- a/src/drive/referrals/index.ts +++ b/src/drive/referrals/index.ts @@ -15,7 +15,7 @@ export class Referrals { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/drive/share/index.ts b/src/drive/share/index.ts index 758bb901..afcaa356 100644 --- a/src/drive/share/index.ts +++ b/src/drive/share/index.ts @@ -46,7 +46,7 @@ export class Share { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/drive/storage/index.ts b/src/drive/storage/index.ts index 148befa9..c2f0df13 100644 --- a/src/drive/storage/index.ts +++ b/src/drive/storage/index.ts @@ -60,7 +60,7 @@ export class Storage { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/drive/trash/index.ts b/src/drive/trash/index.ts index baac70fd..7de93101 100644 --- a/src/drive/trash/index.ts +++ b/src/drive/trash/index.ts @@ -21,7 +21,7 @@ export class Trash { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/drive/users/index.ts b/src/drive/users/index.ts index 859d410d..36d79385 100644 --- a/src/drive/users/index.ts +++ b/src/drive/users/index.ts @@ -32,7 +32,7 @@ export class Users { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity?.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity?.unauthorizedCallback, apiSecurity?.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/meet/index.ts b/src/meet/index.ts index 1c9f2215..13aab6d8 100644 --- a/src/meet/index.ts +++ b/src/meet/index.ts @@ -9,7 +9,7 @@ export class Meet { private readonly apiSecurity?: ApiSecurity; private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity?: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity?.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity?.unauthorizedCallback, apiSecurity?.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/payments/checkout.ts b/src/payments/checkout.ts index 3346d94b..8e6867e3 100644 --- a/src/payments/checkout.ts +++ b/src/payments/checkout.ts @@ -22,7 +22,7 @@ export class Checkout { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/src/shared/http/client.ts b/src/shared/http/client.ts index 2b5178c4..1d6605ad 100644 --- a/src/shared/http/client.ts +++ b/src/shared/http/client.ts @@ -1,6 +1,7 @@ -import axios, { AxiosError, AxiosInstance, AxiosResponse, CancelToken, InternalAxiosRequestConfig } from 'axios'; +import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import AppError from '../types/errors'; import { Headers, Parameters, RequestCanceler, URL, UnauthorizedCallback } from './types'; +import { RetryOptions, retryWithBackoff } from './retryWithBackoff'; export { RequestCanceler } from './types'; @@ -17,27 +18,47 @@ export interface CustomInterceptor { }; } +type NonZero = N extends 0 ? never : N; + +type GlobalRetryOptions = Omit & { + maxRetries?: NonZero; +}; + export class HttpClient { private readonly axios: AxiosInstance; private readonly unauthorizedCallback: UnauthorizedCallback; + private retryOptions?: RetryOptions; static globalInterceptors: CustomInterceptor[] = []; + static globalRetryOptions?: RetryOptions; static setGlobalInterceptors(interceptors: CustomInterceptor[]): void { HttpClient.globalInterceptors = interceptors; } - public static create(baseURL: URL, unauthorizedCallback?: UnauthorizedCallback) { + /** + * Enables global retry with backoff for rate limit errors (429) across every HttpClient instance. + * @param [options] - Optional retry configuration options + * @param [options.maxRetries] - Maximum number of retry attempts (default: 5) + * @param [options.maxRetryAfter] - Maximum wait time in ms regardless of retry-after header value (default: 70000) + * @param [options.onRetry] - Callback invoked before each retry with the attempt number and delay in ms + */ + static enableGlobalRetry(options?: GlobalRetryOptions): void { + HttpClient.globalRetryOptions = (options ?? {}) as RetryOptions; + } + + public static create(baseURL: URL, unauthorizedCallback?: UnauthorizedCallback, retryOptions?: RetryOptions) { if (unauthorizedCallback === undefined) { unauthorizedCallback = () => null; } - return new HttpClient(baseURL, unauthorizedCallback); + return new HttpClient(baseURL, unauthorizedCallback, retryOptions); } - private constructor(baseURL: URL, unauthorizedCallback: UnauthorizedCallback) { + private constructor(baseURL: URL, unauthorizedCallback: UnauthorizedCallback, retryOptions?: RetryOptions) { this.axios = axios.create({ baseURL: baseURL, }); this.unauthorizedCallback = unauthorizedCallback; + this.retryOptions = retryOptions; HttpClient.globalInterceptors.forEach((interceptor) => { if (interceptor.request) { @@ -51,15 +72,21 @@ export class HttpClient { this.initializeMiddleware(); } + private execute(fn: () => Promise): Promise { + const options = this.retryOptions ?? HttpClient.globalRetryOptions; + if (!options) { + return fn(); + } + return retryWithBackoff(fn, options); + } + /** * Requests a GET * @param url * @param headers */ public get(url: URL, headers: Headers): Promise { - return this.axios.get(url, { - headers: headers, - }); + return this.execute(() => this.axios.get(url, { headers })); } /** @@ -69,10 +96,7 @@ export class HttpClient { * @param headers */ public getWithParams(url: URL, params: Parameters, headers: Headers): Promise { - return this.axios.get(url, { - params, - headers, - }); + return this.execute(() => this.axios.get(url, { params, headers })); } /** @@ -87,18 +111,16 @@ export class HttpClient { promise: Promise; requestCanceler: RequestCanceler; } { - const cancelTokenSource = axios.CancelToken.source(); - const config: RequestConfig = { - headers: headers, - cancelToken: cancelTokenSource.token, - }; - const promise = this.axios.get(url, config); - return { - promise: promise, - requestCanceler: { - cancel: cancelTokenSource.cancel, - }, - }; + let currentCancel: RequestCanceler['cancel'] = () => {}; + const requestCanceler: RequestCanceler = { cancel: (message) => currentCancel(message) }; + + const promise = this.execute(() => { + const source = axios.CancelToken.source(); + currentCancel = source.cancel; + return this.axios.get(url, { headers, cancelToken: source.token }); + }); + + return { promise, requestCanceler }; } /** @@ -108,9 +130,7 @@ export class HttpClient { * @param headers */ public post(url: URL, params: Parameters, headers: Headers): Promise { - return this.axios.post(url, params, { - headers: headers, - }); + return this.execute(() => this.axios.post(url, params, { headers })); } /** @@ -120,9 +140,7 @@ export class HttpClient { * @param headers */ public postForm(url: URL, params: Parameters, headers: Headers): Promise { - return this.axios.postForm(url, params, { - headers: headers, - }); + return this.execute(() => this.axios.postForm(url, params, { headers })); } /** @@ -139,18 +157,16 @@ export class HttpClient { promise: Promise; requestCanceler: RequestCanceler; } { - const cancelTokenSource = axios.CancelToken.source(); - const config: RequestConfig = { - headers: headers, - cancelToken: cancelTokenSource.token, - }; - const promise = this.axios.post(url, params, config); - return { - promise: promise, - requestCanceler: { - cancel: cancelTokenSource.cancel, - }, - }; + let currentCancel: RequestCanceler['cancel'] = () => {}; + const requestCanceler: RequestCanceler = { cancel: (message) => currentCancel(message) }; + + const promise = this.execute(() => { + const source = axios.CancelToken.source(); + currentCancel = source.cancel; + return this.axios.post(url, params, { headers, cancelToken: source.token }); + }); + + return { promise, requestCanceler }; } /** @@ -160,9 +176,7 @@ export class HttpClient { * @param headers */ public patch(url: URL, params: Parameters, headers: Headers): Promise { - return this.axios.patch(url, params, { - headers: headers, - }); + return this.execute(() => this.axios.patch(url, params, { headers })); } /** @@ -172,9 +186,7 @@ export class HttpClient { * @param headers */ public put(url: URL, params: Parameters, headers: Headers): Promise { - return this.axios.put(url, params, { - headers: headers, - }); + return this.execute(() => this.axios.put(url, params, { headers })); } /** @@ -184,9 +196,7 @@ export class HttpClient { * @param headers */ public putForm(url: URL, params: Parameters, headers: Headers): Promise { - return this.axios.putForm(url, params, { - headers: headers, - }); + return this.execute(() => this.axios.putForm(url, params, { headers })); } /** @@ -196,10 +206,7 @@ export class HttpClient { * @param params */ public delete(url: URL, headers: Headers, params?: Parameters): Promise { - return this.axios.delete(url, { - headers: headers, - data: params, - }); + return this.execute(() => this.axios.delete(url, { headers, data: params })); } /** @@ -255,9 +262,3 @@ export class HttpClient { throw new AppError(errorMessage, errorStatus, errorCode, errorHeaders); } } - -interface RequestConfig { - headers: Headers; - cancelToken?: CancelToken; - data?: Record; -} diff --git a/src/shared/http/retryWithBackoff.ts b/src/shared/http/retryWithBackoff.ts new file mode 100644 index 00000000..ea504897 --- /dev/null +++ b/src/shared/http/retryWithBackoff.ts @@ -0,0 +1,86 @@ +export interface RetryOptions { + maxRetries?: number; + maxRetryAfter?: number; + onRetry?: (attempt: number, delay: number) => void; +} +interface ErrorWithStatus { + status?: number; + headers?: Record; +} + +const HTTP_STATUS_TOO_MANY_REQUESTS = 429; + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const isErrorWithStatus = (error: unknown): error is ErrorWithStatus => { + return typeof error === 'object' && error !== null; +}; + +const isRateLimitError = (error: unknown): boolean => { + if (!isErrorWithStatus(error)) { + return false; + } + return error.status === HTTP_STATUS_TOO_MANY_REQUESTS; +}; + +const extractRetryAfter = (error: ErrorWithStatus): number | undefined => { + const headers = error.headers; + const resetHeader = headers?.['retry-after']; + if (!resetHeader) { + return undefined; + } + + const resetValueInSeconds = Number.parseInt(resetHeader, 10); + if (Number.isNaN(resetValueInSeconds)) { + return undefined; + } + + return resetValueInSeconds * 1000; +}; + +/** + * Retries a function when it encounters a rate limit error (429). + * Uses the retry-after header to determine how long to wait before retrying. + * + * @param fn - The async function to execute with retry logic + * @param options - Configuration options for retry behavior + * @param options.maxRetries - Maximum number of retry attempts (default: 5) + * @param options.maxRetryAfter - Maximum wait time in ms regardless of retry-after header value (default: 70000) + * @param options.onRetry - Optional callback invoked before each retry with attempt number and delay in ms + * @returns The result of the function if successful + * @throws The original error if it's not a rate limit error, if max retries exceeded, or if retry-after header is missing + */ +export const retryWithBackoff = async (fn: () => Promise, options: RetryOptions = {}): Promise => { + const opts = { + maxRetries: 5, + maxRetryAfter: 70_000, + onRetry: () => {}, + ...options, + }; + + let lastError: unknown; + for (let attempt = 1; attempt <= opts.maxRetries; attempt++) { + try { + return await fn(); + } catch (error: unknown) { + if (!isRateLimitError(error)) { + throw error; + } + + const retryAfter = extractRetryAfter(error as ErrorWithStatus); + + if (!retryAfter) { + throw error; + } + const delay = Math.min(retryAfter, opts.maxRetryAfter); + + opts.onRetry(attempt, delay); + + lastError = error; + await wait(delay); + } + } + const err = lastError as Error; + err.message = `Max retries exceeded: ${err.message}`; + throw err; +}; diff --git a/src/shared/index.ts b/src/shared/index.ts index 3be46417..e585ea6c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,2 +1,5 @@ export * from './types/apiConnection'; export { default as AppError } from './types/errors'; +export { HttpClient } from './http/client'; +export { retryWithBackoff } from './http/retryWithBackoff'; +export type { RetryOptions } from './http/retryWithBackoff'; diff --git a/src/shared/types/apiConnection.ts b/src/shared/types/apiConnection.ts index 8b0f3d93..c0db45cc 100644 --- a/src/shared/types/apiConnection.ts +++ b/src/shared/types/apiConnection.ts @@ -1,4 +1,5 @@ import { Token } from '../../auth'; +import { RetryOptions } from '../http/retryWithBackoff'; import { UnauthorizedCallback } from '../http/types'; export type ApiUrl = string; @@ -14,4 +15,5 @@ export interface ApiSecurity { token: Token; workspaceToken?: Token; unauthorizedCallback?: UnauthorizedCallback; + retryOptions?: RetryOptions; } diff --git a/src/workspaces/index.ts b/src/workspaces/index.ts index e128c961..2d185ab0 100644 --- a/src/workspaces/index.ts +++ b/src/workspaces/index.ts @@ -47,7 +47,7 @@ export class Workspaces { } private constructor(apiUrl: ApiUrl, appDetails: AppDetails, apiSecurity: ApiSecurity) { - this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback); + this.client = HttpClient.create(apiUrl, apiSecurity.unauthorizedCallback, apiSecurity.retryOptions); this.appDetails = appDetails; this.apiSecurity = apiSecurity; } diff --git a/test/shared/http/client.test.ts b/test/shared/http/client.test.ts index 3c6c082b..fda9ecfc 100644 --- a/test/shared/http/client.test.ts +++ b/test/shared/http/client.test.ts @@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { HttpClient } from '../../../src/shared/http/client'; import { UnauthorizedCallback } from '../../../src/shared/http/types'; +import * as retryModule from '../../../src/shared/http/retryWithBackoff'; import { fail } from 'assert'; describe('HttpClient', () => { beforeEach(() => { vi.restoreAllMocks(); + HttpClient.globalRetryOptions = undefined; }); describe('construction', () => { @@ -317,6 +319,51 @@ describe('HttpClient', () => { }); }); }); + + describe('retry options', () => { + let retrySpy: ReturnType; + + beforeEach(() => { + vi.spyOn(axios.Axios.prototype, 'get').mockResolvedValue({}); + retrySpy = vi.spyOn(retryModule, 'retryWithBackoff').mockImplementation((fn) => fn()); + }); + + it('should not use retry when no instance or global retry options are set', async () => { + const client = HttpClient.create(''); + + await client.get('/path', {}); + + expect(retrySpy).not.toHaveBeenCalled(); + }); + + it('should use instance retry options when provided', async () => { + const instanceOptions = { maxRetries: 2 }; + const client = HttpClient.create('', undefined, instanceOptions); + + await client.get('/path', {}); + + expect(retrySpy).toHaveBeenCalledWith(expect.any(Function), instanceOptions); + }); + + it('should fall back to global retry options when no instance options are set', async () => { + HttpClient.enableGlobalRetry({ maxRetries: 5 }); + const client = HttpClient.create(''); + + await client.get('/path', {}); + + expect(retrySpy).toHaveBeenCalledWith(expect.any(Function), HttpClient.globalRetryOptions); + }); + + it('should prefer instance retry options over global retry options', async () => { + const instanceOptions = { maxRetries: 2 }; + HttpClient.enableGlobalRetry({ maxRetries: 5 }); + const client = HttpClient.create('', undefined, instanceOptions); + + await client.get('/path', {}); + + expect(retrySpy).toHaveBeenCalledWith(expect.any(Function), instanceOptions); + }); + }); }); function getAxiosError(): AxiosError { diff --git a/test/shared/http/retryWithBackoff.test.ts b/test/shared/http/retryWithBackoff.test.ts new file mode 100644 index 00000000..6f8cf526 --- /dev/null +++ b/test/shared/http/retryWithBackoff.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { retryWithBackoff } from '../../../src/shared/http/retryWithBackoff'; + +const createRateLimitError = (retryAfterSeconds: string, additionalHeaders?: Record) => ({ + status: 429, + headers: { 'retry-after': retryAfterSeconds, ...additionalHeaders }, +}); + +const createRateLimitMock = (resetDelay: string, additionalHeaders?: Record) => + vi.fn().mockRejectedValueOnce(createRateLimitError(resetDelay, additionalHeaders)).mockResolvedValueOnce('success'); + +describe('retryWithBackoff', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('when function succeeds immediately then returns result without retry', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const mockFn = vi.fn().mockResolvedValue('success'); + + const result = await retryWithBackoff(mockFn); + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + it('when rate limited then retries with delay from headers', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const mockFn = createRateLimitMock('5'); + + const promise = retryWithBackoff(mockFn); + await vi.advanceTimersByTimeAsync(5000); + const result = await promise; + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + }); + + it('when error is not rate limit then throws immediately without retry', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const mockFn = vi.fn().mockRejectedValue({ status: 500, message: 'Internal Server Error' }); + + await expect(retryWithBackoff(mockFn)).rejects.toEqual({ status: 500, message: 'Internal Server Error' }); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + it('when rate limited multiple times then calls onRetry for each retry', async () => { + const error = createRateLimitError('1'); + const mockFn = vi.fn().mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockResolvedValueOnce('success'); + + const onRetry = vi.fn(); + + const promise = retryWithBackoff(mockFn, { onRetry }); + await vi.advanceTimersByTimeAsync(2000); + const result = await promise; + + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(3); + expect(onRetry).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenNthCalledWith(1, 1, 1000); + expect(onRetry).toHaveBeenNthCalledWith(2, 2, 1000); + }); + + it('when headers missing or invalid then throws error', async () => { + const testCases = [ + { status: 429 }, + { status: 429, headers: {} }, + { status: 429, headers: { 'retry-after': 'invalid' } }, + ]; + + for (const error of testCases) { + const mockFn = vi.fn().mockRejectedValue(error); + await expect(retryWithBackoff(mockFn)).rejects.toEqual(error); + expect(mockFn).toHaveBeenCalledTimes(1); + vi.clearAllMocks(); + } + }); + + it('when max retries exceeded then throws original error', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const error = new Error('Rate limit exceeded'); + Object.assign(error, { status: 429, headers: { 'retry-after': '1' } }); + const mockFn = vi.fn().mockRejectedValue(error); + + const promise = retryWithBackoff(mockFn, { maxRetries: 2 }); + const expectation = expect(promise).rejects.toThrow('Rate limit exceeded'); + await vi.advanceTimersByTimeAsync(3000); + await expectation; + + expect(mockFn).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toHaveBeenCalledTimes(2); + }); + + it('when server asks to wait longer than the default limit then waits only up to the default limit', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const mockFn = createRateLimitMock('3600'); + + const promise = retryWithBackoff(mockFn); + await vi.advanceTimersByTimeAsync(70_000); + const result = await promise; + + expect(result).toBe('success'); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 70_000); + }); + + it('when server asks to wait longer than a custom limit then waits only up to that limit', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const mockFn = createRateLimitMock('120'); + + const promise = retryWithBackoff(mockFn, { maxRetryAfter: 10_000 }); + await vi.advanceTimersByTimeAsync(10_000); + const result = await promise; + + expect(result).toBe('success'); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10_000); + }); + + it('when server asks to wait less than the limit then waits the exact requested time', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const mockFn = createRateLimitMock('5'); + + const promise = retryWithBackoff(mockFn); + await vi.advanceTimersByTimeAsync(5_000); + const result = await promise; + + expect(result).toBe('success'); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5_000); + }); + + it('when error is not an object then throws immediately without retry', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const testCases = ['string error', null]; + + for (const error of testCases) { + const mockFn = vi.fn().mockRejectedValue(error); + await expect(retryWithBackoff(mockFn)).rejects.toBe(error); + expect(mockFn).toHaveBeenCalledTimes(1); + vi.clearAllMocks(); + } + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); +});