Files
time_to_leave/src/lib/api-service.ts
T

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 };