Replace the Headers branch's any cast with a typed copy into a Record so the shared API client keeps the same header merge behavior without a no-explicit-any warning. Verification: pnpm --filter @bytelyst/api-client build; pnpm --filter @bytelyst/api-client test; pnpm --filter @bytelyst/api-client exec eslint . --ext .ts,.tsx; pnpm lint.
152 lines
4.3 KiB
TypeScript
152 lines
4.3 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: Record<string, string> = {};
|
|
if (options.headers instanceof Headers) {
|
|
options.headers.forEach((value, key) => {
|
|
extra[key] = value;
|
|
});
|
|
} else if (Array.isArray(options.headers)) {
|
|
Object.assign(extra, Object.fromEntries(options.headers));
|
|
} else {
|
|
Object.assign(extra, options.headers);
|
|
}
|
|
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' };
|
|
}
|
|
},
|
|
};
|
|
}
|