DEV Community

Cover image for Build a Bulletproof Retry Mechanism
CodeZera
CodeZera

Posted on

Build a Bulletproof Retry Mechanism

Alright, lets build something interesting.

I'm pretty sure if you have worked with web applications then you might have noticed some failed fetch operations, its is very common.

A good production app will always have a retry mechanism to handle failed request that will just retry the request with same payload if something goes wrong.

Let's walk through building a retry mechanism using RxJS from scratch. We'll start simple, and then level up to a more advanced, production-ready solution.


The Problem

You call an API. It fails. That's it. You're stuck unless you handle that failure.

Let's say you're using fetch to call an API endpoint. If it fails even once, your stream dies unless you explicitly handle retries. Here's a simple solution to fix that.

Solution-1: Simple Retry Mechanism

import { from, throwError } from "rxjs";
import { switchMap, retry, catchError } from "rxjs/operators";

const fetchData = () => {
  return from(fetch("https://jsonplaceholder.typicode.com/todos/1")).pipe(
    switchMap((response) => {
      if (!response.ok) {
        return throwError(
          () => new Error(`HTTP error! status: ${response.status}`)
        );
      }
      return from(response.json());
    }),
    retry(3)
  );
};
fetchData().subscribe({
  next: (data) => console.log("Fetched data:", data),
  error: (err) => {
    console.error("Request failed after 3 retries:", err);
    console.error("Final error:", err);
  },
});
Enter fullscreen mode Exit fullscreen mode

Image description

Lets understand this step by step.

  • from: Converts the fetch() promise into an observable so we can use rxjs operators on it.

  • switchMap: Takes the Response object, starts a inner loop(a function in this case) and returns another observable from response.json(), basically flattening the result.

  • retry: Pretty self explanatory, if for some reason pipeline throws an error, it will retry upto 3 times before stopping.

  • subscribe: Triggers the stream and handles success and failure.

This works fine but its still pretty basic. No backoff, no timeout, no typesafety. All errors are treated equally.

Lets build a advanced version of this.

Solution 2: Advanced Retry Mechanism

import { from, throwError, timer, Observable } from "rxjs";
import {
  switchMap,
  retryWhen,
  timeout,
  catchError,
  mergeMap,
} from "rxjs/operators";

function fetchWithRetry<T>(
  url: string,
  options?: RequestInit,
  maxRetries = 3,
  timeoutMs = 10000
): Observable<T> {
  return from(fetch(url, options)).pipe(
    timeout(timeoutMs),
    switchMap((response) => {
      if (!response.ok) {
        if (response.status >= 400 && response.status < 500) {
          return throwError(
            () => new Error(`Client error: ${response.status}`)
          );
        }
        return throwError(() => new Error(`Server error: ${response.status}`));
      }
      return from(response.json() as Promise<T>);
    }),
    retry({
      count: maxRetries,
      delay: (error, attempt) => {
        if (error.message.includes("Client error")) {
          return throwError(() => error);
        }

        // 2 seconds backoff time
        return timer(2000);
      },
    })
  );
}
interface Todo {
  userId: number;
  id: number;
  title: "string;"
  completed: boolean;
}
fetchWithRetry<Todo>("https://jsonplaceholder.typicode.com/todos/1").subscribe({
  next: (data) => console.log("Success:", data),
  error: (err) => console.error("Error:", err.message),
  complete: () => console.log("Complete"),
});
Enter fullscreen mode Exit fullscreen mode

Image description

Don't worry this just looks complicated its actually very easy when you understand it. Lets understand this step by step!

  • timeout: Pretty self explanatory, if the request hangs longer than expected we just back off.
  • switchMap: Same role -> Flattening response.json() from promise to observable.
  • retry with custom delay: we first enter the number of tries in the count and in the delay function we first check if the error is from client, if yes then throw error and exit otherwise retry after 2 seconds

  • subscibe: then finally we just subscribe to this stream and listen for events like next, error, and complete which are just simple callbacks you get to perform further operations.

You might be confused on why we return sometimes a function sometimes called timer function and sometimes a throwError function. Why cant we just throw errors directly.

Just remember one small thing, rxjs operators and functions works with respect to time. So all of the things returned are just observables.

I know this can be a lot in starting but trust me read this a couple of times, this stuff is actually very easy and interesting once you start understanding the individual pieces.


Wrapping Up

Retries aren't optional anymore. With RxJS, you get fine-grained control over retry logic and once you understand the building blocks like switchMap, retryWhen, and timer, it becomes surprisingly clean to implement.

Start simple. Then level up as your app's needs evolve.


Connect with Me

If you enjoyed this post and want to stay in the loop with similar content, feel free to follow and connect with me across the web:

Your support means a lot, and I'd love to connect, collaborate, or just geek out over cool projects. Looking forward to hearing from you!


Top comments (0)