@hoangsonw/fast-fetch
Version:
A smarter fetch() wrapper with auto-retry, deduplication, and minimal boilerplate.
151 lines (140 loc) • 4.63 kB
text/typescript
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,
);
}
}
}