TypeScriptExample · free36 lines
The code you asked for
A fetch wrapper with a timeout that retries network failures and 5xx responses using exponential backoff.
TypeScript
export interface RetryOptions { attempts?: number; initialDelay?: number; timeoutMs?: number;} /** Retries network failures and 5xx responses with exponential backoff. */export async function fetchRetry( url: string, init: RequestInit = {}, { attempts = 3, initialDelay = 400, timeoutMs = 10_000 }: RetryOptions = {},): Promise<Response> { let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { ...init, signal: controller.signal }); // 4xx is a client error: retrying returns the same response. if (response.ok || response.status < 500) return response; lastError = new Error("HTTP " + response.status); } catch (error) { lastError = error; } finally { clearTimeout(timer); } if (attempt < attempts - 1) { const delay = initialDelay * 2 ** attempt + Math.random() * 100; await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError instanceof Error ? lastError : new Error("fetch failed");}How it works
- Every attempt builds its own `AbortController`; when the timeout fires the request is cancelled and lands in the error branch.
- A 4xx response does not break the loop, it returns straight away: retrying a client error yields the same answer and burns both gems and time.
- The wait doubles — `400ms → 800ms → 1600ms` — with 0–100ms of random jitter on top, so a thousand clients that fail together do not all come back in the same second.
- If the last attempt fails too, the original captured error is rethrown; the wrapper never swallows it.
Watch out
- `clearTimeout` sits in `finally` so it runs on the error path too; otherwise the timer leaks and the Node process will not exit.
- Do not blindly retry non-idempotent requests such as POST — without an idempotency key on the server, the same order can be placed twice.
- If you pass your own cancellation via `init.signal`, this code overrides it; you have to combine the two signals (`AbortSignal.any`).
How to test it
- Mock the server to return 503 twice and 200 on the third call; assert `fetch` ran three times and the final response came back.
- For an endpoint returning 404, assert `fetch` ran exactly once (4xx is not retried).
- Trigger the timeout with fake timers (`vi.useFakeTimers`) and check the `AbortError` is caught.