feat(api-client): add timeout, retry with exponential backoff for idempotent requests
This commit is contained in:
parent
a97b730a89
commit
04f4a5f81e
@ -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 }));
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user