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.
|
* 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';
|
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.
|
* 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 {
|
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 {
|
function buildHeaders(options?: RequestInit): HeadersInit {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@ -51,12 +65,55 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
|
|||||||
return headers;
|
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 {
|
return {
|
||||||
async fetch<T>(path: string, options?: RequestInit): Promise<T> {
|
async fetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await globalThis.fetch(`${baseUrl}${path}`, {
|
const init = buildInit(options);
|
||||||
...options,
|
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
|
||||||
headers: buildHeaders(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
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>> {
|
async safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>> {
|
||||||
try {
|
try {
|
||||||
const res = await globalThis.fetch(`${baseUrl}${path}`, {
|
const init = buildInit(options);
|
||||||
...options,
|
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
|
||||||
headers: buildHeaders(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
|||||||
@ -2,6 +2,12 @@ export interface ApiClientConfig {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
getToken?: () => string | null;
|
getToken?: () => string | null;
|
||||||
defaultHeaders?: Record<string, string>;
|
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> {
|
export interface ApiResult<T> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user