From 04f4a5f81ee4362123777b8bdd284d6a4b835807 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 20:23:49 -0800 Subject: [PATCH] feat(api-client): add timeout, retry with exponential backoff for idempotent requests --- packages/api-client/src/client.ts | 75 ++++++++++++++++++++++++++----- packages/api-client/src/types.ts | 6 +++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 8f276a47..8c1d3044 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,10 +1,17 @@ /** * Configurable API client factory. - * Creates a fetch wrapper with base URL, auth token injection, and error handling. + * Creates a fetch wrapper with base URL, auth token injection, error handling, + * timeout, and retry with exponential backoff for idempotent requests. */ import type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + /** * Create an API client with a base URL and optional auth token. * @@ -23,7 +30,14 @@ import type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; * ``` */ export function createApiClient(config: ApiClientConfig): ApiClient { - const { baseUrl, getToken, defaultHeaders } = config; + const { + baseUrl, + getToken, + defaultHeaders, + timeoutMs = 10_000, + retries = 2, + retryDelayMs = 500, + } = config; function buildHeaders(options?: RequestInit): HeadersInit { const headers: Record = { @@ -51,12 +65,55 @@ export function createApiClient(config: ApiClientConfig): ApiClient { return headers; } + function buildInit(options?: RequestInit): RequestInit { + const init: RequestInit = { + ...options, + headers: buildHeaders(options), + }; + + // AbortController timeout (skip if caller already supplies a signal or timeoutMs is 0) + if (timeoutMs > 0 && !options?.signal) { + const controller = new AbortController(); + init.signal = controller.signal; + setTimeout(() => controller.abort(), timeoutMs); + } + + return init; + } + + function isRetryable(method: string | undefined): boolean { + return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase()); + } + + async function fetchWithRetry(url: string, init: RequestInit): Promise { + const maxAttempts = isRetryable(init.method) ? retries + 1 : 1; + let lastError: unknown; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const res = await globalThis.fetch(url, init); + // Only retry on 502/503/504 for idempotent methods + if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts - 1) { + await sleep(retryDelayMs * 2 ** attempt); + continue; + } + return res; + } catch (err) { + lastError = err; + if (attempt < maxAttempts - 1) { + await sleep(retryDelayMs * 2 ** attempt); + continue; + } + } + } + + throw lastError; + } + return { async fetch(path: string, options?: RequestInit): Promise { - const res = await globalThis.fetch(`${baseUrl}${path}`, { - ...options, - headers: buildHeaders(options), - }); + const init = buildInit(options); + const res = await fetchWithRetry(`${baseUrl}${path}`, init); if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); @@ -68,10 +125,8 @@ export function createApiClient(config: ApiClientConfig): ApiClient { async safeFetch(path: string, options?: RequestInit): Promise> { try { - const res = await globalThis.fetch(`${baseUrl}${path}`, { - ...options, - headers: buildHeaders(options), - }); + const init = buildInit(options); + const res = await fetchWithRetry(`${baseUrl}${path}`, init); if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 338bbf3b..a4252a65 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -2,6 +2,12 @@ export interface ApiClientConfig { baseUrl: string; getToken?: () => string | null; defaultHeaders?: Record; + /** Request timeout in milliseconds. Default: 10000 (10s). Set 0 to disable. */ + timeoutMs?: number; + /** Max retries for idempotent requests (GET/HEAD/OPTIONS). Default: 2. */ + retries?: number; + /** Base delay in ms for exponential backoff between retries. Default: 500. */ + retryDelayMs?: number; } export interface ApiResult {