learning_ai_common_plat/packages/api-client/src/client.ts

147 lines
4.2 KiB
TypeScript

/**
* Configurable API client factory.
* 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.
*
* @example
* ```ts
* const api = createApiClient({
* baseUrl: "/api",
* getToken: () => localStorage.getItem("access_token"),
* });
*
* // Throws on error
* const users = await api.fetch<User[]>("/users");
*
* // Never throws
* const { data, error } = await api.safeFetch<User[]>("/users");
* ```
*/
export function createApiClient(config: ApiClientConfig): ApiClient {
const {
baseUrl,
getToken,
defaultHeaders,
timeoutMs = 10_000,
retries = 2,
retryDelayMs = 500,
} = config;
function buildHeaders(options?: RequestInit): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
...defaultHeaders,
};
if (getToken) {
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
if (options?.headers) {
const extra =
options.headers instanceof Headers
? Object.fromEntries(options.headers as any)
: Array.isArray(options.headers)
? Object.fromEntries(options.headers)
: (options.headers as Record<string, string>);
Object.assign(headers, extra);
}
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 init = buildInit(options);
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
},
async safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>> {
try {
const init = buildInit(options);
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || `HTTP ${res.status}` };
}
const data = (await res.json()) as T;
return { data, error: null };
} catch {
return { data: null, error: 'API unavailable' };
}
},
};
}