Retry Failed API Requests with TypeScript
Build a typed retry wrapper in TypeScript with exponential backoff. Learn to type retry configuration, handle transient failures, and preserve response types across retries.
Retrying a failed API request means catching the error, waiting, and trying again. In TypeScript, you can build a typed retry wrapper that preserves the response type through every attempt. The caller gets back the same typed result whether the function succeeded on the first try or the fifth.
The wrapper needs three things: a decision about which errors are retryable, a waiting strategy between attempts, and a limit on how many times to try.
A Basic Typed Retry Wrapper
Start with a simple wrapper that retries a function a fixed number of times, waiting between each attempt.
async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts: number
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
throw new Error("Unreachable");
}The generic type T preserves whatever the inner function returns. If fn returns a Promise of User, withRetry returns a Promise of User. The type flows through untouched.
The wait time increases with each attempt: one second before the second try, two seconds before the third, and so on. This is linear backoff. Exponential backoff is better for production use.
Deciding Which Errors to Retry
Not every error should trigger a retry. A 404 means the resource does not exist, so retrying will not help. A 503 means the server is temporarily overloaded, so waiting and retrying might succeed.
function shouldRetry(error: unknown): boolean {
if (error instanceof ApiError) {
return error.status >= 500 || error.status === 429;
}
return true;
}Server errors and rate limits are retryable. Client errors are not. Network errors that are not ApiError instances should also be retried, since they are usually transient. The ApiError class here is a custom error that carries the HTTP status code.
Exponential Backoff with Jitter
Exponential backoff doubles the wait time after each attempt. Jitter adds randomness to prevent many clients from retrying at exactly the same moment.
function getDelay(attempt: number): number {
const base = Math.pow(2, attempt) * 100;
const jitter = Math.random() * 100;
return base + jitter;
}The wait times are roughly 200ms, 400ms, 800ms, 1600ms, with up to 100ms of random jitter added to each. This spreads out retry attempts from multiple clients.
The diagram shows the retry loop. The function is called, and if it fails, the wrapper checks whether the error is retryable and whether attempts remain. If both conditions pass, it waits and tries again.
The Complete Typed Retry Wrapper
Combining all the pieces produces a production-ready retry function.
First, define a configuration interface so callers can customize the retry behavior explicitly.
interface RetryConfig {
maxAttempts: number;
baseDelayMs: number;
shouldRetry: (error: unknown) => boolean;
}The config object makes max attempts, delay timing, and retry decisions explicit. Callers can adjust behavior per endpoint without touching the wrapper code. A small wait helper turns a delay into an awaitable pause:
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}Now build the wrapper that uses exponential backoff with jitter. It loops up to maxAttempts times, returning immediately on success. On failure, it throws right away if the error is not retryable or attempts are exhausted, otherwise it waits before the next attempt:
async function withRetry<T>(
fn: () => Promise<T>,
config: RetryConfig
): Promise<T> {
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === config.maxAttempts - 1 || !config.shouldRetry(error)) throw error;
await wait(Math.pow(2, attempt) * config.baseDelayMs + Math.random() * config.baseDelayMs);
}
}
throw new Error("Unreachable");
}Adding AbortController Support
Retries can take a long time. Let callers cancel the entire retry sequence by threading an AbortSignal through the config. The wrapper checks the signal at the start of every attempt with throwIfAborted, and again after a failure before deciding to retry:
async function withRetry<T>(fn: (signal: AbortSignal) => Promise<T>, config: RetryConfig & { signal?: AbortSignal }): Promise<T> {
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
config.signal?.throwIfAborted();
try {
return await fn(config.signal!);
} catch (error) {
if (config.signal?.aborted || attempt === config.maxAttempts - 1 || !config.shouldRetry(error)) throw error;
await wait(Math.pow(2, attempt) * config.baseDelayMs);
}
}
throw new Error("Unreachable");
}The throwIfAborted check at the start of each iteration lets the caller stop retries immediately. The signal is also passed to fn so individual requests can be aborted, not just the retry loop.
Common Mistakes
Retrying on 4xx errors. A 400, 401, 403, or 404 means the request itself is wrong. Retrying sends the same wrong request again. Fix the request instead of retrying.
Not setting a maximum retry count. Without a limit, a persistent server error causes an infinite loop. Always cap retries at a reasonable number, typically three to five attempts.
Retrying without backoff. Sending retries immediately after a failure gives the server no time to recover and can make the problem worse. Always include a delay between attempts, and increase the delay with each retry.
For the error types used by the retry decision logic, see handle API errors in TypeScript. For cancelling retries in progress, see cancel async requests in TypeScript. For the fetch wrapper being retried, see build a typed fetch wrapper.
Rune AI
Key Insights
- Retry only on transient failures: 5xx errors, 429, and network errors.
- Use exponential backoff with jitter to avoid overwhelming the server.
- Preserve the response type through the retry wrapper with a generic parameter.
- AbortController lets callers cancel retries, not just individual requests.
- Never retry on 4xx errors. The request itself must change before retrying.
Frequently Asked Questions
Which HTTP status codes should trigger a retry?
How does exponential backoff work in a retry wrapper?
Should I retry on network errors?
Conclusion
A typed retry wrapper takes a function that returns a promise, wraps it with retry logic, and preserves the original return type. The wrapper decides whether to retry based on the error type and HTTP status code, waits with exponential backoff, and either eventually returns the successful result or throws after exhausting all attempts. The generic type parameter ensures that callers get back the exact type they expect, whether the function succeeded on the first try or the fifth.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.