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.

7 min read

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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

Retry with exponential backoff flow

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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Which HTTP status codes should trigger a retry?

Retry on 5xx server errors (500, 502, 503, 504) and 429 (Too Many Requests). Never retry on 4xx client errors like 400, 401, 403, or 404, because the request itself is the problem and retrying will not fix it.

How does exponential backoff work in a retry wrapper?

After each failed attempt, wait before retrying. The wait time doubles each attempt: 1s, 2s, 4s, 8s. This gives the server time to recover and prevents overwhelming it with rapid retries. A jitter factor adds randomness to avoid thundering herd problems.

Should I retry on network errors?

Yes. Network errors like fetch failing with TypeError are usually transient. The connection may recover on the next attempt. These are different from HTTP errors and should be retried regardless of the status code logic.

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.