feat(api-client): add timeout, retry with exponential backoff for idempotent requests

This commit is contained in:
saravanakumardb1 2026-02-28 20:23:49 -08:00
parent a97b730a89
commit 04f4a5f81e
2 changed files with 71 additions and 10 deletions

View File

@ -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<void> {
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<string, string> = {
@ -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<Response> {
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<T>(path: string, options?: RequestInit): Promise<T> {
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<T>(path: string, options?: RequestInit): Promise<ApiResult<T>> {
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 }));

View File

@ -2,6 +2,12 @@ export interface ApiClientConfig {
baseUrl: string;
getToken?: () => string | null;
defaultHeaders?: Record<string, string>;
/** 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<T> {