From a9efbfabd9b967541b632ccb91a7dea2ce499123 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 18 Feb 2026 17:48:14 -0400 Subject: [PATCH 1/5] feat: add global retry with backoff to HttpClient for rate limit handling Introduces retryWithBackoff utility that automatically retries requests on 429 responses using the retry-after header delay. Integrates it into all HttpClient HTTP methods via a withRetry wrapper, with configurable options (maxRetries, onRetry) exposed through setGlobalRetryOptions. --- src/shared/http/client.ts | 96 ++++++++---------- src/shared/http/retryWithBackoff.ts | 80 +++++++++++++++ src/shared/index.ts | 3 + test/shared/http/retryWithBackoff.test.ts | 116 ++++++++++++++++++++++ 4 files changed, 238 insertions(+), 57 deletions(-) create mode 100644 src/shared/http/retryWithBackoff.ts create mode 100644 test/shared/http/retryWithBackoff.test.ts diff --git a/src/shared/http/client.ts b/src/shared/http/client.ts index 2b5178c..33de584 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'; @@ -21,11 +22,16 @@ export class HttpClient { private readonly axios: AxiosInstance; private readonly unauthorizedCallback: UnauthorizedCallback; static globalInterceptors: CustomInterceptor[] = []; + static retryOptions: RetryOptions = {}; static setGlobalInterceptors(interceptors: CustomInterceptor[]): void { HttpClient.globalInterceptors = interceptors; } + static setGlobalRetryOptions(options: RetryOptions): void { + HttpClient.retryOptions = options; + } + public static create(baseURL: URL, unauthorizedCallback?: UnauthorizedCallback) { if (unauthorizedCallback === undefined) { unauthorizedCallback = () => null; @@ -51,15 +57,17 @@ export class HttpClient { this.initializeMiddleware(); } + private withRetry(fn: () => Promise): Promise { + return retryWithBackoff(fn, HttpClient.retryOptions); + } + /** * Requests a GET * @param url * @param headers */ public get(url: URL, headers: Headers): Promise { - return this.axios.get(url, { - headers: headers, - }); + return this.withRetry(() => this.axios.get(url, { headers })); } /** @@ -69,10 +77,7 @@ export class HttpClient { * @param headers */ public getWithParams(url: URL, params: Parameters, headers: Headers): Promise { - return this.axios.get(url, { - params, - headers, - }); + return this.withRetry(() => this.axios.get(url, { params, headers })); } /** @@ -87,18 +92,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.withRetry(() => { + const source = axios.CancelToken.source(); + currentCancel = source.cancel; + return this.axios.get(url, { headers, cancelToken: source.token }); + }); + + return { promise, requestCanceler }; } /** @@ -108,9 +111,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.withRetry(() => this.axios.post(url, params, { headers })); } /** @@ -120,9 +121,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.withRetry(() => this.axios.postForm(url, params, { headers })); } /** @@ -139,18 +138,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.withRetry(() => { + const source = axios.CancelToken.source(); + currentCancel = source.cancel; + return this.axios.post(url, params, { headers, cancelToken: source.token }); + }); + + return { promise, requestCanceler }; } /** @@ -160,9 +157,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.withRetry(() => this.axios.patch(url, params, { headers })); } /** @@ -172,9 +167,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.withRetry(() => this.axios.put(url, params, { headers })); } /** @@ -184,9 +177,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.withRetry(() => this.axios.putForm(url, params, { headers })); } /** @@ -196,10 +187,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.withRetry(() => this.axios.delete(url, { headers, data: params })); } /** @@ -255,9 +243,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 0000000..f04cd6f --- /dev/null +++ b/src/shared/http/retryWithBackoff.ts @@ -0,0 +1,80 @@ +export interface RetryOptions { + maxRetries?: 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.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, + onRetry: () => {}, + ...options, + }; + + for (let attempt = 0; 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; + } + + opts.onRetry(attempt + 1, retryAfter); + + await wait(retryAfter); + } + } + + return await fn(); +}; diff --git a/src/shared/index.ts b/src/shared/index.ts index 3be4641..e585ea6 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/test/shared/http/retryWithBackoff.test.ts b/test/shared/http/retryWithBackoff.test.ts new file mode 100644 index 0000000..807f2f2 --- /dev/null +++ b/test/shared/http/retryWithBackoff.test.ts @@ -0,0 +1,116 @@ +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(3); + expect(setTimeoutSpy).toHaveBeenCalledTimes(2); + }); + + 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(); + }); +}); From ee141dc98c79eebe5fc10978ea649c41d620b522 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 18 Feb 2026 18:01:44 -0400 Subject: [PATCH 2/5] feat: add global retry options configuration for HttpClient --- src/shared/http/client.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/shared/http/client.ts b/src/shared/http/client.ts index 33de584..fb1fbc9 100644 --- a/src/shared/http/client.ts +++ b/src/shared/http/client.ts @@ -28,6 +28,12 @@ export class HttpClient { HttpClient.globalInterceptors = interceptors; } + /** + * Configures global retry options applied to all HTTP methods across every HttpClient instance. + * @param options - Retry configuration options + * @param options.maxRetries - Maximum number of retry attempts on rate limit errors (default: 5) + * @param options.onRetry - Optional callback invoked before each retry with the attempt number and delay in ms + */ static setGlobalRetryOptions(options: RetryOptions): void { HttpClient.retryOptions = options; } From d3a9f0c48454bcb4c783b9b179419ddd7e50ec86 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 20 Feb 2026 23:56:17 -0400 Subject: [PATCH 3/5] feat: implement global retry options with maxRetryAfter for HttpClient --- src/shared/http/client.ts | 46 ++++++++++++++--------- src/shared/http/retryWithBackoff.ts | 18 ++++++--- test/shared/http/retryWithBackoff.test.ts | 38 ++++++++++++++++++- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/shared/http/client.ts b/src/shared/http/client.ts index fb1fbc9..0422d75 100644 --- a/src/shared/http/client.ts +++ b/src/shared/http/client.ts @@ -18,24 +18,31 @@ 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; static globalInterceptors: CustomInterceptor[] = []; - static retryOptions: RetryOptions = {}; + static retryOptions?: RetryOptions; static setGlobalInterceptors(interceptors: CustomInterceptor[]): void { HttpClient.globalInterceptors = interceptors; } /** - * Configures global retry options applied to all HTTP methods across every HttpClient instance. - * @param options - Retry configuration options - * @param options.maxRetries - Maximum number of retry attempts on rate limit errors (default: 5) - * @param options.onRetry - Optional callback invoked before each retry with the attempt number and delay in ms + * 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 setGlobalRetryOptions(options: RetryOptions): void { - HttpClient.retryOptions = options; + static enableGlobalRetry(options?: GlobalRetryOptions): void { + HttpClient.retryOptions = (options ?? {}) as RetryOptions; } public static create(baseURL: URL, unauthorizedCallback?: UnauthorizedCallback) { @@ -63,7 +70,10 @@ export class HttpClient { this.initializeMiddleware(); } - private withRetry(fn: () => Promise): Promise { + private execute(fn: () => Promise): Promise { + if (!HttpClient.retryOptions) { + return fn(); + } return retryWithBackoff(fn, HttpClient.retryOptions); } @@ -73,7 +83,7 @@ export class HttpClient { * @param headers */ public get(url: URL, headers: Headers): Promise { - return this.withRetry(() => this.axios.get(url, { headers })); + return this.execute(() => this.axios.get(url, { headers })); } /** @@ -83,7 +93,7 @@ export class HttpClient { * @param headers */ public getWithParams(url: URL, params: Parameters, headers: Headers): Promise { - return this.withRetry(() => this.axios.get(url, { params, headers })); + return this.execute(() => this.axios.get(url, { params, headers })); } /** @@ -101,7 +111,7 @@ export class HttpClient { let currentCancel: RequestCanceler['cancel'] = () => {}; const requestCanceler: RequestCanceler = { cancel: (message) => currentCancel(message) }; - const promise = this.withRetry(() => { + const promise = this.execute(() => { const source = axios.CancelToken.source(); currentCancel = source.cancel; return this.axios.get(url, { headers, cancelToken: source.token }); @@ -117,7 +127,7 @@ export class HttpClient { * @param headers */ public post(url: URL, params: Parameters, headers: Headers): Promise { - return this.withRetry(() => this.axios.post(url, params, { headers })); + return this.execute(() => this.axios.post(url, params, { headers })); } /** @@ -127,7 +137,7 @@ export class HttpClient { * @param headers */ public postForm(url: URL, params: Parameters, headers: Headers): Promise { - return this.withRetry(() => this.axios.postForm(url, params, { headers })); + return this.execute(() => this.axios.postForm(url, params, { headers })); } /** @@ -147,7 +157,7 @@ export class HttpClient { let currentCancel: RequestCanceler['cancel'] = () => {}; const requestCanceler: RequestCanceler = { cancel: (message) => currentCancel(message) }; - const promise = this.withRetry(() => { + const promise = this.execute(() => { const source = axios.CancelToken.source(); currentCancel = source.cancel; return this.axios.post(url, params, { headers, cancelToken: source.token }); @@ -163,7 +173,7 @@ export class HttpClient { * @param headers */ public patch(url: URL, params: Parameters, headers: Headers): Promise { - return this.withRetry(() => this.axios.patch(url, params, { headers })); + return this.execute(() => this.axios.patch(url, params, { headers })); } /** @@ -173,7 +183,7 @@ export class HttpClient { * @param headers */ public put(url: URL, params: Parameters, headers: Headers): Promise { - return this.withRetry(() => this.axios.put(url, params, { headers })); + return this.execute(() => this.axios.put(url, params, { headers })); } /** @@ -183,7 +193,7 @@ export class HttpClient { * @param headers */ public putForm(url: URL, params: Parameters, headers: Headers): Promise { - return this.withRetry(() => this.axios.putForm(url, params, { headers })); + return this.execute(() => this.axios.putForm(url, params, { headers })); } /** @@ -193,7 +203,7 @@ export class HttpClient { * @param params */ public delete(url: URL, headers: Headers, params?: Parameters): Promise { - return this.withRetry(() => this.axios.delete(url, { headers, data: params })); + return this.execute(() => this.axios.delete(url, { headers, data: params })); } /** diff --git a/src/shared/http/retryWithBackoff.ts b/src/shared/http/retryWithBackoff.ts index f04cd6f..ea50489 100644 --- a/src/shared/http/retryWithBackoff.ts +++ b/src/shared/http/retryWithBackoff.ts @@ -1,8 +1,8 @@ export interface RetryOptions { maxRetries?: number; + maxRetryAfter?: number; onRetry?: (attempt: number, delay: number) => void; } - interface ErrorWithStatus { status?: number; headers?: Record; @@ -45,6 +45,7 @@ const extractRetryAfter = (error: ErrorWithStatus): number | undefined => { * @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 @@ -52,11 +53,13 @@ const extractRetryAfter = (error: ErrorWithStatus): number | undefined => { export const retryWithBackoff = async (fn: () => Promise, options: RetryOptions = {}): Promise => { const opts = { maxRetries: 5, + maxRetryAfter: 70_000, onRetry: () => {}, ...options, }; - for (let attempt = 0; attempt < opts.maxRetries; attempt++) { + let lastError: unknown; + for (let attempt = 1; attempt <= opts.maxRetries; attempt++) { try { return await fn(); } catch (error: unknown) { @@ -69,12 +72,15 @@ export const retryWithBackoff = async (fn: () => Promise, options: RetryOp if (!retryAfter) { throw error; } + const delay = Math.min(retryAfter, opts.maxRetryAfter); - opts.onRetry(attempt + 1, retryAfter); + opts.onRetry(attempt, delay); - await wait(retryAfter); + lastError = error; + await wait(delay); } } - - return await fn(); + const err = lastError as Error; + err.message = `Max retries exceeded: ${err.message}`; + throw err; }; diff --git a/test/shared/http/retryWithBackoff.test.ts b/test/shared/http/retryWithBackoff.test.ts index 807f2f2..6f8cf52 100644 --- a/test/shared/http/retryWithBackoff.test.ts +++ b/test/shared/http/retryWithBackoff.test.ts @@ -96,10 +96,46 @@ describe('retryWithBackoff', () => { await vi.advanceTimersByTimeAsync(3000); await expectation; - expect(mockFn).toHaveBeenCalledTimes(3); + 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]; From 953e5ed95258f37056fb01fc1101e3e40e42649a Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 23 Feb 2026 14:01:15 -0400 Subject: [PATCH 4/5] feat: make retry options per-instance and configurable via ApiSecurity Allow each service to opt-in to retry independently by passing retryOptions through ApiSecurity, while keeping enableGlobalRetry as a fallback default. Instance options take priority over global options. --- src/auth/index.ts | 2 +- src/drive/backups/index.ts | 2 +- src/drive/payments/index.ts | 2 +- src/drive/referrals/index.ts | 2 +- src/drive/share/index.ts | 2 +- src/drive/storage/index.ts | 2 +- src/drive/trash/index.ts | 2 +- src/drive/users/index.ts | 2 +- src/meet/index.ts | 2 +- src/payments/checkout.ts | 2 +- src/shared/http/client.ts | 17 ++++++----- src/shared/types/apiConnection.ts | 2 ++ src/workspaces/index.ts | 2 +- test/shared/http/client.test.ts | 47 +++++++++++++++++++++++++++++++ 14 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index 5855a47..3a6d7ca 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 d002306..d97087e 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 a62209c..8f68b9c 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 8671308..8c997c4 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 758bb90..afcaa35 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 148befa..c2f0df1 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 baac70f..7de9310 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 859d410..36d7938 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 1c9f221..13aab6d 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 3346d94..8e6867e 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 0422d75..1d6605a 100644 --- a/src/shared/http/client.ts +++ b/src/shared/http/client.ts @@ -27,8 +27,9 @@ type GlobalRetryOptions = Omit(options?: GlobalRetryOptions): void { - HttpClient.retryOptions = (options ?? {}) as RetryOptions; + HttpClient.globalRetryOptions = (options ?? {}) as RetryOptions; } - public static create(baseURL: URL, unauthorizedCallback?: UnauthorizedCallback) { + 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) { @@ -71,10 +73,11 @@ export class HttpClient { } private execute(fn: () => Promise): Promise { - if (!HttpClient.retryOptions) { + const options = this.retryOptions ?? HttpClient.globalRetryOptions; + if (!options) { return fn(); } - return retryWithBackoff(fn, HttpClient.retryOptions); + return retryWithBackoff(fn, options); } /** diff --git a/src/shared/types/apiConnection.ts b/src/shared/types/apiConnection.ts index 8b0f3d9..c0db45c 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 e128c96..2d185ab 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 3c6c082..fda9ecf 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 { From 0a66be971b972b902ffcc6e185910fca949f8fce Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 24 Feb 2026 10:41:52 -0400 Subject: [PATCH 5/5] chore: bump version to 1.15.0 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bab0fd2..47efcf0 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",