Retrying failed HTTP requests in Angular

Original author: Kevin Kreuzer
  • Transfer
Organization of access to server data is the basis of almost any one-page application. All dynamic content in such applications is downloaded from the backend.

In most cases, HTTP requests to the server work reliably and return the desired result. However, in some situations, requests may fail.

Imagine how someone works with your website through an access point in a train that travels around the country at a speed of 200 kilometers per hour. The network connection in this scenario can be slow, but server requests, despite this, do their job.

But what if the train gets into the tunnel? There is a high probability that the connection to the Internet will be interrupted and the web application will not be able to "reach out" to the server. In this case, the user will have to reload the application page after the train leaves the tunnel and the Internet connection is restored.

Reload the page can affect the current state of the application. This means that the user can, for example, lose the data that he entered into the form.

Instead of simply reconciling with the fact that a certain request was unsuccessful, it would be better to repeat it several times and show the user a corresponding notification. With this approach, when the user realizes that the application is trying to cope with the problem, he most likely will not reload the page.



The material, the translation of which we publish today, is devoted to the analysis of several ways of repeating unsuccessful requests in Angular-applications.

Repeat failed requests


Let's reproduce a situation that a user working on the Internet from a train may encounter. We will create a backend that processes the request incorrectly during the first three attempts to access it, returning data only from the fourth attempt.
Usually, using Angular, we create a service, connect it HttpClientand use it to get data from the backend.

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {EMPTY, Observable} from 'rxjs';
import {catchError} from 'rxjs/operators';
@Injectable()
export class GreetingService {
  private GREET_ENDPOINT = 'http://localhost:3000';
  constructor(private httpClient: HttpClient) {
  }
  greet(): Observable {
    return this.httpClient.get(`${this.GREET_ENDPOINT}/greet`).pipe(
      catchError(() => {
        // Выполняем обработку ошибок
        return EMPTY;
      })
    );
  }
}

There is nothing special here. We plug in the Angular module HttpClientand execute a simple GET request. If the request returns an error, we execute some code to process it and return empty Observable(the observed object) in order to inform about this that initiated the request. This code, as it were, says: "There was an error, but everything is in order, I can handle it."

Most applications perform HTTP requests this way. In the above code, the request is executed only once. After that, it either returns data received from the server, or is unsuccessful.

How to repeat the request if the endpoint is /greetunavailable or returns an error? Maybe there is a suitable RxJS statement? Of course it exists. RxJS has operators for everything.

The first thing that may come to mind in this situation is the operator retry. Let's look at its definition: “Returns an Observable that plays the original Observable except error. If the original Observable calls error, then this method, instead of propagating the error, will re-subscribe to the original Observable.

The maximum number of re-subscriptions is limited count(this is the numerical parameter passed to the method). "

The operator is retryvery similar to what we need. So let's embed it in our chain.

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {EMPTY, Observable} from 'rxjs';
import {catchError, retry, shareReplay} from 'rxjs/operators';
@Injectable()
export class GreetingService {
  private GREET_ENDPOINT = 'http://localhost:3000';
  constructor(private httpClient: HttpClient) {
  }
  greet(): Observable {
    return this.httpClient.get(`${this.GREET_ENDPOINT}/greet`).pipe(
      retry(3),
      catchError(() => {
        // Выполняем обработку ошибок
        return EMPTY;
      }),
      shareReplay()
    );
  }
}

We have successfully used the operator retry. Let's look at how this affected the behavior of the HTTP request that is executed in the experimental application. Here is a large GIF file that shows the screen of this application and the Network tab of the browser developer tools. You will find several more such demonstrations here.

Our application is extremely simple. It just makes an HTTP request when a button is clicked PING THE SERVER.

As already mentioned, the backend returns an error when performing the first three attempts to execute a request to it, and when a fourth request arrives to it, it returns a normal response.

On the Network Developer Tools tab, you can see that the operatorretrysolves the task set before him and repeats the execution of the failed request three times. The last attempt is successful, the application receives a response, a corresponding message appears on the page.

All this is very good. Now the application can repeat failed requests.

However, this example can still be improved. Please note that now repeated requests are executed immediately after the execution of requests that are unsuccessful. This behavior of the system will not bring much benefit in our situation - when the train gets into the tunnel and the Internet connection is lost for a while.

Delayed retry of failed requests


The train that got into the tunnel does not leave it instantly. He spends some time there. Therefore, we need to “stretch” the period during which we perform repeated requests to the server. You can do this by deferring retries.

To do this, we need to better control the process of executing repeated requests. We need to be able to make decisions about when exactly to repeat requests. This means that the operator’s capabilities retryare not enough for us. Therefore, we again turn to the documentation on RxJS.

The documentation contains a description of the operator retryWhen, which seems to suit us. In the documentation, it is described as follows: “Returns an Observable that plays the original Observable except error. If the original Observable callserror, then this method will throw Throwable, which caused an error, Observable returned from notifier. If this Observable calls completeor error, then this method will call completeeither erroron the child subscription. Otherwise, this method will re-subscribe to the original Observable. "

Yes, the definition is not simple. Let's describe the same in a more accessible language.

The operator retryWhenaccepts a callback that returns an Observable. The returned Observable decides how the operator will behave retryWhenbased on some rules. Namely, this is how the operator behaves retryWhen:

  • It stops working and throws an error if the returned Observable throws an error.
  • It exits if the returned Observable reports completion.
  • In other cases, when the Observable returns successfully, it repeats the execution of the original Observable

A callback is only called when the original Observable throws an error for the first time.

Now we can use this knowledge to create a delayed retry mechanism for a failed request using the RxJS operator retryWhen.

retryWhen((errors: Observable) => errors.pipe(
    delay(delayMs),
    mergeMap(error => retries-- > 0 ? of(error) : throwError(getErrorMessage(maxEntry))
    ))
)

If the original Observable, which is our HTTP request, returns an error, then the statement is called retryWhen. In the callback, we have access to the error that caused the failure. We defer errors, reduce the number of retries and return a new Observable that throws an error.

Based on the rules of the operator retryWhen, this Observable, since it returns a value, retries the request. If the repetition is unsuccessful several times and the value of the variable retriesdecreases to 0, then we end the work with an error that occurred while executing the request.

Wonderful! Apparently, we can take the code above and replace with it the operator retrythat is in our chain. But here we slow down a little.

How to deal with a variableretries? This variable contains the current state of the failed request retry system. Where is she announced? When is the condition reset? The state needs to be managed inside the stream, not outside it.

▍Create your own delayedRetry statement


We can solve the problem of state management and improve the readability of the code by writing the above code as a separate RxJS operator.

There are different ways to create your own RxJS operators. Which method to use depends on how the particular operator is structured.

Our operator is based on existing RxJS operators. As a result, we can use the simplest way to create our own operators. In our case, the RxJs operator is just a function with the following signature:

const customOperator = (src: Observable) => Observable

This statement takes the original Observable and returns another Observable.

Since our operator allows the user to specify how often repeated requests should be executed, and how many times they need to be executed, we need to wrap the above function declaration in a factory function that takes values delayMs(delay between retries) and maxRetry(maximum number repetitions).

const customOperator = (delayMs: number, maxRetry: number) => {
   return (src: Observable) => Observable
}

If you want to create an operator that is not based on existing operators, you need to pay attention to handling errors and subscriptions. Moreover, you will need to extend the class Observableand implement the function lift.

If you are interested, take a look
here .

So, based on the above code snippets, let's write our own RxJs operator.

import {Observable, of, throwError} from 'rxjs';
import {delay, mergeMap, retryWhen} from 'rxjs/operators';
const getErrorMessage = (maxRetry: number) =>
  `Tried to load Resource over XHR for ${maxRetry} times without success. Giving up`;
const DEFAULT_MAX_RETRIES = 5;
export function delayedRetry(delayMs: number, maxRetry = DEFAULT_MAX_RETRIES) {
  let retries = maxRetry;
  return (src: Observable) =>
    src.pipe(
      retryWhen((errors: Observable) => errors.pipe(
        delay(delayMs),
        mergeMap(error => retries-- > 0 ? of(error) : throwError(getErrorMessage(maxRetry))
        ))
      )
    );
}

Excellent. Now we can import this operator into the client code. We will use it when executing an HTTP request.

return this.httpClient.get(`${this.GREET_ENDPOINT}/greet`).pipe(
        delayedRetry(1000, 3),
        catchError(error => {
            console.log(error);
            // Выполняем обработку ошибок
            return EMPTY;
        }),
        shareReplay()
    );

We put the operator delayedRetryin a chain and passed it, as parameters, the numbers 1000 and 3. The first parameter sets the delay in milliseconds between attempts to make repeated requests. The second parameter determines the maximum number of repeated requests.

Restart the application and look at how the new operator works.

After analyzing the behavior of the program using the tools of the browser developer, we can see that the execution of repeated attempts to execute the request is delayed for a second. After receiving the correct answer to the request, a corresponding message will appear in the application window.

Exponential request snoozing


Let's develop the idea of ​​delayed retry of failed requests. Previously, we always delayed the execution of each of the repeated requests at the same time.

Here we talk about how to increase the delay after each attempt. The first attempt to retry the request is made after a second, the second after two seconds, the third after three.

Create a new operator, retryWithBackoffwhich implements this behavior.

import {Observable, of, throwError} from 'rxjs';
import {delay, mergeMap, retryWhen} from 'rxjs/operators';
const getErrorMessage = (maxRetry: number) =>
  `Tried to load Resource over XHR for ${maxRetry} times without success. Giving up.`;
const DEFAULT_MAX_RETRIES = 5;
const DEFAULT_BACKOFF = 1000;
export function retryWithBackoff(delayMs: number, maxRetry = DEFAULT_MAX_RETRIES, backoffMs = DEFAULT_BACKOFF) {
  let retries = maxRetry;
  return (src: Observable) =>
    src.pipe(
      retryWhen((errors: Observable) => errors.pipe(
        mergeMap(error => {
            if (retries-- > 0) {
              const backoffTime = delayMs + (maxRetry - retries) * backoffMs;
              return of(error).pipe(delay(backoffTime));
            }
            return throwError(getErrorMessage(maxRetry));
          }
        ))));
}

If you use this operator in the application and test it, you can see how the delay in executing the repeated request increases after each new attempt.

After each attempt, we wait a certain time, repeat the request and increase the wait time. Here, as usual, after the server returns the correct answer to the request, we display a message in the application window.

Summary


Repeating failed HTTP requests makes applications more stable. This is especially significant when performing very important queries, without the data obtained through which, the application cannot work normally. For example, it can be configuration data containing the addresses of the servers with which the application needs to interact.

In most scenarios, the RxJs statement is retrynot enough to provide a reliable system for retrying failed requests. The operator retryWhengives the developer a higher level of control over repeated requests. It allows you to configure the interval for repeated requests. Due to the capabilities of this operator, it is possible to implement a delayed repetition scheme or exponentially delayed repetitions.

When implementing behavior patterns suitable for reuse in RxJS chains, it is recommended that they be formatted as new operators. Here is the repository from which the code was used in this article.

Dear readers! How do you solve the problem of retrying failed HTTP requests?


Also popular now: