435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
// ============================================================================
|
|
// API Service Wrapper — Rate Limiting, Exponential Backoff, and Caching
|
|
// ============================================================================
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// NOTE: ApiError is a class (not interface) because it is instantiated with `new`.
|
|
export class ApiError extends Error {
|
|
status?: number;
|
|
body?: unknown;
|
|
isRateLimit?: boolean;
|
|
isRetryable?: boolean;
|
|
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
export interface RetryOptions {
|
|
/** Maximum number of retry attempts. Default: 3 */
|
|
maxRetries?: number;
|
|
/** Base delay in ms before the first retry. Default: 1000 */
|
|
baseDelayMs?: number;
|
|
/** Maximum delay cap in ms. Default: 30_000 */
|
|
maxDelayMs?: number;
|
|
/** Jitter factor (0-1). Adds randomness to prevent thundering herd. Default: 0.3 */
|
|
jitter?: number;
|
|
/** HTTP status codes that should trigger a retry. Default: [429, 500, 502, 503, 504] */
|
|
retryableStatuses?: number[];
|
|
}
|
|
|
|
export interface CacheEntry<T> {
|
|
data: T;
|
|
timestamp: number;
|
|
ttl: number;
|
|
}
|
|
|
|
export interface CacheStats {
|
|
size: number;
|
|
hits: number;
|
|
misses: number;
|
|
hitRate: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-Memory Cache with TTL support and LRU eviction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class MemoryCache<T = unknown> {
|
|
private store = new Map<string, CacheEntry<T>>();
|
|
private _defaultTtl: number;
|
|
private _maxSize: number;
|
|
private _hits = 0;
|
|
private _misses = 0;
|
|
|
|
constructor(options: { defaultTtlMs?: number; maxSize?: number } = {}) {
|
|
this._defaultTtl = options.defaultTtlMs ?? 15 * 60 * 1000; // 15 minutes
|
|
this._maxSize = options.maxSize ?? 500;
|
|
}
|
|
|
|
get(key: string): T | null {
|
|
const entry = this.store.get(key);
|
|
|
|
if (!entry) {
|
|
this._misses++;
|
|
return null;
|
|
}
|
|
|
|
// Check TTL expiration
|
|
if (Date.now() - entry.timestamp > entry.ttl) {
|
|
this.store.delete(key);
|
|
this._misses++;
|
|
return null;
|
|
}
|
|
|
|
this._hits++;
|
|
return entry.data;
|
|
}
|
|
|
|
set(key: string, data: T, ttl?: number): void {
|
|
// Evict oldest entries if over capacity
|
|
if (this.store.size >= this._maxSize && !this.store.has(key)) {
|
|
const oldestKey = this.store.keys().next().value;
|
|
if (oldestKey) this.store.delete(oldestKey);
|
|
}
|
|
|
|
this.store.set(key, {
|
|
data,
|
|
timestamp: Date.now(),
|
|
ttl: ttl ?? this._defaultTtl,
|
|
});
|
|
}
|
|
|
|
invalidate(key: string): boolean {
|
|
return this.store.delete(key);
|
|
}
|
|
|
|
clear(): void {
|
|
this.store.clear();
|
|
}
|
|
|
|
stats(): CacheStats {
|
|
const total = this._hits + this._misses;
|
|
return {
|
|
size: this.store.size,
|
|
hits: this._hits,
|
|
misses: this._misses,
|
|
hitRate: total > 0 ? this._hits / total : 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Retry Logic with Exponential Backoff + Jitter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function calculateBackoff(attempt: number, baseDelayMs: number, maxDelayMs: number, jitter: number): number {
|
|
// Exponential backoff: baseDelay * 2^attempt
|
|
const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
|
|
const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
|
|
|
|
// Add jitter to prevent thundering herd
|
|
const jitterRange = cappedDelay * jitter;
|
|
const jitterValue = Math.random() * jitterRange;
|
|
|
|
return cappedDelay + jitterValue;
|
|
}
|
|
|
|
function isRetryableError(error: unknown, retryableStatuses: number[]): boolean {
|
|
if (error instanceof ApiError) {
|
|
const status = error.status;
|
|
if (status !== undefined && retryableStatuses.includes(status)) {
|
|
return true;
|
|
}
|
|
// Network errors (no status code) are retryable
|
|
if (status === undefined) {
|
|
return true;
|
|
}
|
|
}
|
|
// Generic network/timeout errors
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
return true;
|
|
}
|
|
if (error instanceof TypeError) {
|
|
// Network failures often throw TypeError in browsers/Node
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function fetchWithRetry(
|
|
url: string | URL | Request,
|
|
init?: RequestInit,
|
|
options: RetryOptions = {},
|
|
): Promise<Response> {
|
|
const maxRetries = options.maxRetries ?? 3;
|
|
const baseDelayMs = options.baseDelayMs ?? 1000;
|
|
const maxDelayMs = options.maxDelayMs ?? 30_000;
|
|
const jitter = options.jitter ?? 0.3;
|
|
const retryableStatuses = options.retryableStatuses ?? [429, 500, 502, 503, 504];
|
|
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, init);
|
|
|
|
// Check if the status is retryable
|
|
if (response.status === 429) {
|
|
// HTTP 429: Too Many Requests — respect Retry-After header
|
|
const retryAfterHeader = response.headers.get("Retry-After");
|
|
let retryAfterMs = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
|
|
|
|
if (retryAfterHeader) {
|
|
const parsed = parseInt(retryAfterHeader, 10);
|
|
if (!isNaN(parsed)) {
|
|
retryAfterMs = Math.max(retryAfterMs, parsed * 1000);
|
|
}
|
|
}
|
|
|
|
if (attempt < maxRetries) {
|
|
lastError = new ApiError(`Rate limited (HTTP 429). Retrying in ${Math.round(retryAfterMs)}ms…`);
|
|
(lastError as ApiError).status = 429;
|
|
(lastError as ApiError).isRateLimit = true;
|
|
(lastError as ApiError).isRetryable = true;
|
|
|
|
await sleep(retryAfterMs);
|
|
continue;
|
|
}
|
|
|
|
// Exhausted retries — throw
|
|
const err = new ApiError("Rate limit exceeded after all retries");
|
|
err.status = 429;
|
|
(err as ApiError).isRateLimit = true;
|
|
(err as ApiError).isRetryable = false;
|
|
throw err;
|
|
}
|
|
|
|
if (!response.ok && retryableStatuses.includes(response.status)) {
|
|
const delay = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
|
|
|
|
if (attempt < maxRetries) {
|
|
lastError = new ApiError(`Server error (HTTP ${response.status}). Retrying in ${Math.round(delay)}ms…`);
|
|
(lastError as ApiError).status = response.status;
|
|
(lastError as ApiError).isRetryable = true;
|
|
|
|
await sleep(delay);
|
|
continue;
|
|
}
|
|
|
|
// Exhausted retries — throw
|
|
const err = new ApiError(`Server error after all retries: HTTP ${response.status}`);
|
|
err.status = response.status;
|
|
(err as ApiError).isRetryable = false;
|
|
throw err;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
lastError = error;
|
|
|
|
// If it's already a non-retryable ApiError, throw immediately
|
|
if (error instanceof ApiError && !(error as ApiError).isRetryable) {
|
|
throw error;
|
|
}
|
|
|
|
if (attempt < maxRetries && isRetryableError(error, retryableStatuses)) {
|
|
const delay = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
|
|
await sleep(delay);
|
|
continue;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Should not reach here, but TypeScript needs it
|
|
throw lastError ?? new ApiError("Unexpected fetch failure");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cached Fetch Wrapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CachedFetchOptions extends RetryOptions {
|
|
cacheKey?: string;
|
|
ttl?: number;
|
|
skipCache?: boolean;
|
|
}
|
|
|
|
async function cachedFetch<T>(
|
|
cache: MemoryCache<unknown>,
|
|
url: string | URL | Request,
|
|
init?: RequestInit,
|
|
options: CachedFetchOptions = {},
|
|
): Promise<T> {
|
|
const key = options.cacheKey ?? String(url);
|
|
|
|
// Check cache (unless skipped)
|
|
if (!options.skipCache) {
|
|
const cached = cache.get(key);
|
|
if (cached !== null) {
|
|
return cached as T;
|
|
}
|
|
}
|
|
|
|
const response = await fetchWithRetry(url, init, options);
|
|
|
|
if (!response.ok) {
|
|
const err = new ApiError(`HTTP ${response.status}: ${response.statusText}`);
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
const data = (await response.json()) as T;
|
|
cache.set(key, data, options.ttl);
|
|
|
|
return data;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// APIClient — Composable Base Class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ApiClientOptions {
|
|
baseUrl: string;
|
|
defaultTimeoutMs?: number;
|
|
defaultTtlMs?: number;
|
|
maxRetries?: number;
|
|
userAgent?: string;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
export class ApiClient {
|
|
public readonly baseUrl: string;
|
|
public readonly cache: MemoryCache<unknown>;
|
|
public readonly defaultTimeoutMs: number;
|
|
public readonly defaultTtlMs: number;
|
|
public readonly maxRetries: number;
|
|
public readonly userAgent?: string;
|
|
public readonly headers: Record<string, string>;
|
|
|
|
constructor(options: ApiClientOptions) {
|
|
this.baseUrl = options.baseUrl;
|
|
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 12_000;
|
|
this.defaultTtlMs = options.defaultTtlMs ?? 15 * 60 * 1000;
|
|
this.maxRetries = options.maxRetries ?? 3;
|
|
this.userAgent = options.userAgent;
|
|
this.headers = { ...options.headers };
|
|
|
|
if (this.userAgent) {
|
|
this.headers["User-Agent"] = this.userAgent;
|
|
}
|
|
|
|
this.cache = new MemoryCache({ defaultTtlMs: this.defaultTtlMs });
|
|
}
|
|
|
|
/**
|
|
* Build a common headers object, merging defaults with overrides.
|
|
*/
|
|
protected buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
return {
|
|
...this.headers,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build an AbortSignal that fires after the configured timeout.
|
|
*/
|
|
protected buildTimeoutSignal(timeoutMs?: number): AbortSignal {
|
|
const ms = timeoutMs ?? this.defaultTimeoutMs;
|
|
return AbortSignal.timeout(ms);
|
|
}
|
|
|
|
/**
|
|
* Perform a GET request with retry + caching.
|
|
*/
|
|
protected async get<T>(
|
|
path: string,
|
|
search?: Record<string, string>,
|
|
options: {
|
|
ttl?: number;
|
|
cacheKey?: string;
|
|
skipCache?: boolean;
|
|
maxRetries?: number;
|
|
timeoutMs?: number;
|
|
headers?: Record<string, string>;
|
|
} = {},
|
|
): Promise<T> {
|
|
const url = new URL(path, this.baseUrl);
|
|
|
|
if (search) {
|
|
for (const [k, v] of Object.entries(search)) {
|
|
url.searchParams.set(k, v);
|
|
}
|
|
}
|
|
|
|
return cachedFetch<T>(
|
|
this.cache,
|
|
url.toString(),
|
|
{
|
|
headers: this.buildHeaders(options.headers),
|
|
signal: this.buildTimeoutSignal(options.timeoutMs),
|
|
},
|
|
{
|
|
cacheKey: options.cacheKey ?? url.toString(),
|
|
ttl: options.ttl,
|
|
skipCache: options.skipCache,
|
|
maxRetries: options.maxRetries ?? this.maxRetries,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Perform a POST request with retry logic (never cached).
|
|
*/
|
|
protected async post<T>(
|
|
path: string,
|
|
body: unknown,
|
|
options: {
|
|
maxRetries?: number;
|
|
timeoutMs?: number;
|
|
headers?: Record<string, string>;
|
|
} = {},
|
|
): Promise<T> {
|
|
const url = new URL(path, this.baseUrl);
|
|
|
|
return cachedFetch<T>(
|
|
this.cache,
|
|
url.toString(),
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
...this.buildHeaders(options.headers),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: this.buildTimeoutSignal(options.timeoutMs),
|
|
},
|
|
{
|
|
skipCache: true,
|
|
maxRetries: options.maxRetries ?? this.maxRetries,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics.
|
|
*/
|
|
public cacheStats(): CacheStats {
|
|
return this.cache.stats();
|
|
}
|
|
|
|
/**
|
|
* Clear the cache.
|
|
*/
|
|
public clearCache(): void {
|
|
this.cache.clear();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exports
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export { fetchWithRetry, cachedFetch, calculateBackoff, sleep };
|