UNPKG

@hoangsonw/fast-fetch

Version:

A smarter fetch() wrapper with auto-retry, deduplication, and minimal boilerplate.

151 lines (140 loc) 4.63 kB
import fetch from "cross-fetch"; /** * Options for controlling FastFetch behavior: * - `retries` -> number of times to retry on failure * - `retryDelay` -> base delay in ms before next retry (will be multiplied exponentially) * - `deduplicate` -> whether to merge identical requests in-flight * - `shouldRetry` -> custom logic to decide if we retry */ export interface FastFetchOptions { retries?: number; retryDelay?: number; deduplicate?: boolean; shouldRetry?: (error: any, attempt: number) => boolean; } /** * Ongoing in-flight requests for deduplication: * Keyed by a stable signature of { url, method, headers, body } */ const inFlightMap = new Map<string, Promise<Response>>(); /** * Generate a signature for dedup if deduplicate is true */ function makeSignature(input: RequestInfo, init?: RequestInit): string { // Basic approach: const normalized = { url: typeof input === "string" ? input : (input as Request).url, method: init?.method ?? "GET", headers: init?.headers ?? {}, body: init?.body ?? null, }; return JSON.stringify(normalized); } /** * Sleep helper for retryDelay */ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * FastFetch main function * - Retries on error with exponential backoff * - Deduplicates in-flight requests if deduplicate = true */ export async function fastFetch( input: RequestInfo, init?: RequestInit & FastFetchOptions, ): Promise<Response> { const { retries = 0, retryDelay = 1000, deduplicate = true, shouldRetry, } = init || {}; console.log("[FastFetch] Starting request for:", input); // If deduplicating, check if a matching in-flight request exists let signature = ""; if (deduplicate) { signature = makeSignature(input, init); if (inFlightMap.has(signature)) { console.log( "[FastFetch] Found in-flight request for signature:", signature, ); return inFlightMap.get(signature)!; } } // Build a promise chain that tries up to retries + 1 times with exponential backoff let attempt = 0; let promise = (async function fetchWithRetry(): Promise<Response> { while (true) { try { attempt++; console.log(`[FastFetch] Attempt #${attempt} for:`, input); const response = await fetch(input, init); if (!response.ok && shouldRetry) { const doRetry = shouldRetry(response, attempt); console.log( `[FastFetch] Response not ok (status: ${response.status}). Retry decision: ${doRetry}`, ); if (doRetry && attempt <= retries) { const delay = retryDelay * Math.pow(2, attempt - 1); console.log( `[FastFetch] Waiting ${delay}ms (exponential backoff) before retrying...`, ); await sleep(delay); continue; // try again } } console.log(`[FastFetch] Request succeeded on attempt #${attempt}`); return response; } catch (error: any) { console.log(`[FastFetch] Caught error on attempt #${attempt}:`, error); if (shouldRetry) { const doRetry = shouldRetry(error, attempt); console.log(`[FastFetch] Retry decision based on error: ${doRetry}`); if (doRetry && attempt <= retries) { const delay = retryDelay * Math.pow(2, attempt - 1); console.log( `[FastFetch] Waiting ${delay}ms (exponential backoff) before retrying after error...`, ); await sleep(delay); continue; } } else { if (attempt <= retries) { const delay = retryDelay * Math.pow(2, attempt - 1); console.log( `[FastFetch] Retrying attempt #${attempt} after error. Waiting ${delay}ms...`, ); await sleep(delay); continue; } } console.log("[FastFetch] No more retries. Throwing error."); throw error; } } })(); // If deduplicating, store in the map so subsequent calls get the same promise if (deduplicate) { inFlightMap.set(signature, promise); console.log( "[FastFetch] Stored in-flight request with signature:", signature, ); } try { const result = await promise; return result; } finally { // Once done (success or fail), remove from inFlightMap if (deduplicate) { inFlightMap.delete(signature); console.log( "[FastFetch] Removed in-flight record for signature:", signature, ); } } }