fbpx Skip to content

Aquent | DEV6

Angular + RxJS: retryWhen in depth

Written by: Eugene Havrylov

There is a lot of information about the RxJS library on the web. However, searching for a real-life example and a use case can be challenging. I want to provide you with a few scenarios where we can benefit from using a retryWhen operator. The docs say that it “retries an observable sequence on error based on custom criteria”. Let’s find a real-life example for this definition.

We’ll be emulating two situations. In the first case, we receive an error in a servers’ response, process it, and retry the call. In the second case, we retry the API call in specific conditions and retry the call with different request parameters. 

We start by setting up a project. I’ll use a bootstrap stylesheet to simplify the stylings. 

ng new recursive-call --style=scss

npm i -S bootstrap

To integrate bootstrap, go to your angular.json file and edit styles in architect.build.options to:

"styles": [

  "node_modules/bootstrap/dist/css/bootstrap.css",

  "src/styles.scss"

],
 

Also, add the next import to your root styles.scss:

@import "~bootstrap/scss/bootstrap.scss";

Now, we can create our template. We need a simple one. Let’s create a button that triggers API call and a logging area to see what is actually happening under the hood;

App.component.html:

<div class="container vh-100">
  <div class="my-3 d-flex flex-column align-items-center justify-content-between w-100">
    <button class="btn btn-outline-primary" (click)="handleClick()">START</button>
    <div class="p-3 container-fluid d-flex flex-column justify-content-start">
      <label>Logs:</label>
      <div class="jumbotron justify-content-start">
        <div *ngFor="let row of output" class="row">
          <span [ngStyle]="{'color': row === 0 ? 'red' : 'black'}">{{row}}</span>
        </div>
      </div>
    </div>
  </div>
</div>

Create a method handleClick inside of your app.component.ts file and a simple typeless array that is for presentation purposes only:

output = [];
ngOnInit(): void {}

The next steps are only for a server emulation. Thanks to Jason Watmore and his blog-post Angular 7 – Mock Backend Example for Backendless Development, we need to change it just a little bit to achieve fake chaining requests/responses. So, let’s create an interceptor service:

ng g s interceptors/fake-backend

This is what it should look like:

@Injectable()
export class FakeBackendInterceptor implements HttpInterceptor {
  // 0 - error thrown; 1 - successful result
  respMap = [0, 0, 0, 1];

  handleRoute(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    const { url, method, body, params } = req;
    if (url.endsWith("/data") && method === "GET") {
      // Next is just a simulation of server responses:
      if (this.respMap.length) {
        const nextItem = this.respMap.shift();
        return nextItem !== 0 ? this.data(nextItem) : this.error(nextItem);
      } else {
        return next.handle(req);
      }
    } else {
      return next.handle(req);
    }
  }

  data(val) {
    return this.ok(val);
  }
  ok(body?) {
    return of(new HttpResponse({ status: 200, body }));
  }
  error(err) {
    return throwError(err);
  }
  constructor() {}
  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return (
      of(null)
        .pipe(mergeMap(() => this.handleRoute(req, next)))
        // call materialize and dematerialize to ensure delay even if an error is thrown (https://github.com/Reactive-Extensions/RxJS/issues/648)
        .pipe(materialize())
        .pipe(delay(500))
        .pipe(dematerialize())
    );
  }
}

export const fakeBackendProvider = {
  // use fake backend in place of Http service for backend-less development
  provide: HTTP_INTERCEPTORS,
  useClass: FakeBackendInterceptor,
  multi: true
};

I won’t dwell on it because it is only used due to the lack of a real backend, but I want to tell more about the chaining part of it. We have respMap = [0, 0, 0, 1]; that represents what kind of response we will get. Each time we call ‘/data’ route, we remove the first value from the array and check the value; if it’s 0 then throw an error, if not, return the response with status 200. As long as we have values in our array, we don’t do an actual API call. We intercept it and conditionally return the response.

Interceptor is ready, and now we need to add it to the app.module.ts:

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule],
  providers: [fakeBackendProvider] // <--,
  bootstrap: [AppComponent]
})
export class AppModule {}

Now, create a new service that’s going to handle our requests:

ng g s services/api

Inside of the newly generated service, provide HttpClient in the constructor:

constructor(private httpClient: HttpClient) {}

And create a method that will do a call to our /data route:

getData() {
  return this.httpClient.get("/data");
}

Launch the app (npm run start), and you should have the app up and running and see the following on your screen:

Finally, we are ready for our first use case of retryWhen operator! We are going to retry the initial API call when we get an error. We know that during the first three API calls we won’t be able to get a successful result (remember that response map array? [error, error, error, ok]).

When can we have a situation like that?

Assume that your server is down, and you are getting 5xx errors because of that. Usually, we don’t want to make users refresh a page and repeat their actions. That is why we might do that under the hood and ping our server in a certain interval within a specified time-frame.

Another scenario is: connecting to an IoT device that might send us a status ‘loading’ instead of ‘ready’. In that case we need to ask it for status updates before we start working with its interfaces. There are tons of different scenarios when we might need this, and RxJS offers a beautiful approach to do that.

Add the API service to the app.component.ts constructor method:

constructor(private apiService: ApiService) {}

Edit your handleClick method:

// Let's repeat the call when we have an error:
handleClick() {
  this.apiService.getData().pipe(
      take(1),
      // Next for presentational purposes;
      // We want to display the error output alongside with successful result
      catchError(err => {
        this.output.push(err);
        return throwError(err);
      }),
      retryWhen(result =>
        result.pipe(
          concatMap(result => {
            // here we can check the error.
            // We can specify the retry only if we are getting 5xx errors for instance.
            if (result === 0) {
              return of(result);
            }
            // in other cases we throw an error down the pipe
            return throwError(result);
          }),
          delay(1000),
          // we can keep calling forever but usually we want to avoid this.
          // So, we set the number of attempts including the initial one.
          take(4),
          o => concat(o, throwError(`Sorry, there was no result after 3 retries)`))
        )
      ),
      tap(result => this.output.push(result))
    )
    .subscribe(() => {}, error => this.output.push(error));
}
  • First of all, we call our API. Take it only once, since we don’t want this observable to be running after the call is completed. For the logging purposes only, we embedded the catchError method before retryWhen and passed it down with the throwError operator.
  • Every error that receives a parent observable goes through retryWhen. That is why we need to pipe into the parent observable to get its result value. The resulting value can be unwrapped with concatMap, switchMap, or flatMap operators.
  • Now, we need to check what type of error we have. In our case, it’s just the number: 0 since we threw it from our interceptor. However, it can be any error from the response, and we can conditionally choose what we want to do if we get it. We want to retry the API call if we have 0, and throw an error down in all other cases.
  • After, we can set a delay between repetitions to avoid DoS rejection. Also, we might want to specify the number of retries. In other cases, it can be infinite. Keep in mind that this number includes the initial attempt. So, if you want to retry three times after the first one, you have to provide: take(4).
  • The last thing, if we exceeded all our retry attempts, we throw the custom error with the help of the concat operator.

Here is what we’ve done so far. Press the Start button and you can see the next output:

As we can see, the first call returned an error as well as two other subsequent attempts did. On the fourth time, it got the OK result and finished all future retries.

Now, we can reduce the number of repetitions and see the error of exceeding attempts. Set take(3), and start again. You should see the following message:

 

The second part is going to be about modifying an initial API call in custom conditions.   Let’s assume that we need to fetch an invoice for the upcoming period. Our server generates invoices for the next unpaid period, respectively.

There is a chance that a user has already paid for the next month, so our server will return no invoices in a successful response. However, we can keep calling the endpoint until we get the month where the invoice won’t be paid yet so that we can display it with the right due date.

To achieve this, we need to perform some changes to our handleClick() method and to adapt our fake server.

Go to the fake-backend.service.ts and add an interface that represents our response invoice model to the top of the file before @Injectable…:

export interface InvoiceResponse {
  invoices?: number[];
  dueDate?: Date;
  error?: any;
}

Inside the service, create a variable that we’ll use as a response mapping array. The same way we did before, just with value as an object instead of a single number:

invoiceRespMap: InvoiceResponse[] = [
  { invoices: [], dueDate: new Date(2019, 9, 12) },
  { invoices: [], dueDate: new Date(2019, 10, 12) },
  { invoices: [], dueDate: new Date(2019, 11, 12) },
  { invoices: [100], dueDate: new Date(2020, 0, 12) }
];

You can see that in our imaginable DB, we don’t have any invoices for the next three months. We will go through these months until we get to the unpaid invoice. Modify the handleRoute method to add a new route:

handleRoute(
  req: HttpRequest<any>,
  next: HttpHandler
): Observable<HttpEvent<any>> {
  const { url, method, body, params } = req;
  if (url.endsWith("/data") && method === "GET") {
    // Next is just a simulation of server responses:
    if (this.respMap.length) {
      const nextItem = this.respMap.shift();
      return nextItem !== 0 ? this.data(nextItem) : this.error(nextItem);
    } else {
      return next.handle(req);
    }
    // ==> Handling /invoices route:
  } else if (url.endsWith("/invoice") && method === "GET") {
    return this.invoiceRespMap.length
      ? this.data(
          this.invoiceRespMap.find(
            el => el.dueDate.getMonth() === parseInt(params.get("month"))
          )
        )
      : next.handle(req);
  } else {
    return next.handle(req);
  }
}

Add new method to the api.servcice.ts:

getNextInvoice(month: number) {
  return this.httpClient.get("/invoice", {
    params: new HttpParams().set("month", `${month}`)
  });
}

This method requires a month as a parameter and calls the endpoint with the request month.

Next is the trickiest part. We need to increment a month after each API call that returned an empty invoice array. To do that we will change the handleClick method and introduce a new observable that takes an action in incrementing the month.

// Now we can achieve scanning of the url by replacing url params if result doesn't satisfy us:
handleClick() {
  this.getInvoiceFromAPI(new Date())
    .pipe(
      take(1),
      // checking the response object for invoice items.
      // if there are no invoices throw an error to re-fetch them:
      tap(response => {
        if (!response.invoices.length) {
          this.output.push(`Empty month ${response.dueDate.getMonth() + 1}`);
          throw response;
        }
      }),
      retryWhen(result =>
        result.pipe(
          // only if a server returned an error we stop trying and pass the error down
          tap(invoiceItemsResult => {
            if (invoiceItemsResult.error) {
              throw invoiceItemsResult.error;
            }
          }),
          delay(300),
          // we can keep calling forever but usually we want to avoid this.
          // So, we set the number of attempts including the initial one.
          take(4),
          // Attach an error message after we exceed number of attempts:
          o => concat(o, throwError(`Sorry, there was no result after 4 attempts)`))
        )
      ),
      tap(result => this.output.push(result.invoices.join(" ,")))
    )
    .subscribe(() => {}, error => this.output.push(error));
}

getInvoiceFromAPI(date: Date): Observable<InvoiceResponse> {
  let retryAttempt = 0;
  let dateCopy = new Date(date);
  // Important to wrap main call into the parent observable:
  return of(null).pipe(
    concatMap(() => {
      // Add next month for each retry:
      let newDate = new Date(
        dateCopy.setMonth(date.getMonth() + retryAttempt++)
      );
      return this.apiService.getNextInvoice(newDate.getMonth());
    }),
    // mapping error response to the object that will help us to decide whether retry the call or not:
    catchError(err => of({ error: err }))
  );
}

Now let’s go through that step by step.

  • First of all, we created the observable getInvoiceFromAPI that gets the Date as an argument (the current date in our case). Inside this observable, we create two inner variables that hold the retry attempts number and the copy of the provided Date.
  • The next thing is crucial, we have to increment the month inside of the returning observable because retrtyWhen operator will repeat it. So, we created an observable of null, and inside of the concatMap operator modified the date and returned the API call function. In case of server error, we need to bypass an error and to wrap it in a custom object to handle it inside of the retryWhen conditions.
  • In our handleClick method, we listen to the result and check the length of the invoice field of the response object. If it doesn’t have any values, we throw it as an error to trigger the next fetch. Inside of the retryWhen operator, we follow pretty much the same logic as before. The only change is that we didn’t use conditional processing and just passed the real error down to the subscriber. It’s set to have 300ms of a delay between calls and does four retries.

Let’s see it in action:

Here we go! We tried to get invoices for the current month and got nothing. So, we scanned the next months until we’ve got a desirable response. The same way as before, if we don’t get any invoice, we have an error saying that we exceeded our number of retries.

Awesome! We’ve discussed the real-life examples of using retryWhen operator. But there are way more of them.

I strongly believe, that the documentation should be more specific and adapted to particular use cases. That is why, I wanted to cover it in more details. Of course, you might say that all of these scenarios should be handled by the backend. Unfortunately, it is not always possible. From my experience as a front-end engineer, I can say for sure that the backend might not be as flexible as we want. Therefore, retrying the API calls is sometimes the only way to get the required data. RxJS provides an elegant way to perform these kinds of tasks and to avoid a promise-hell along the way.