angularrxjsangular-httpclient

Retry a request for a number of times in Angular app


I have this situation where I need to make a get request. I do know how to use Angular's http client, but I need to make the request retry automatically if it fails, for a number of times before giving up and throwing an error, and also I need to put a delay between each retry.

For example, I need my request, if it fails, to retry for a total of 5 times, and between each retry have 10 seconds delay.

Is there an easy and clear way to do something like that?


Solution

  • I have the same need in my angular app, so I created a pipeable operator called retryWithBackoff. It uses exponential backoff, so the time between retries looks like:

    delayMs * Math.pow(2, retries)
    

    It can be used very simply:

      getDiscussionList(): Observable<DiscussionListModel[]> | Observable<never> {
        return this.httpClient
          .get<DiscussionListModel[]>('/api/discussions').pipe(
            retryWithBackoff()
      );
    

    Here is the operator:

    export function retryWithBackoff<T>(
      delayMs: number = 1000,
      maxRetries: number = 5,
      maxTime: number = 12000
    ): MonoTypeOperatorFunction<T> {
      return (source: Observable<T>) => {
        const currentMs = new Date().getTime();
        return source.pipe(
          timeout(maxTime),
          retryWhen(errors => {
            let retries = 0;
            return errors.pipe(
              mergeMap(next => {
                // If we caught TimeoutError we must rethrow because retryWhen would retry
                // if request failed due to timeout.
                // The timeout(maxTime) isn't reached because a new request is sent
                // therefore we have to compare against currentMs
                if ((next instanceof TimeoutError)
                  || (new Date()).getTime() >= currentMs + maxTime) {
                  return throwError(new HttpTimeoutError());
                }
                retries++;
                if (retries >= maxRetries) {
                  return throwError(new HttpMaxRetriesError());
                }
                return timer(delayMs * Math.pow(2, retries));
              }),
            );
          })
        );
      };
    }
    

    It will timeout in 12 seconds, regardless of how many attempts have been made. The two exceptions it can throw are just an empty custom exception class:

    export class HttpEstablishError {}
    export class HttpMaxRetriesError extends HttpEstablishError {}
    export class HttpTimeoutError extends HttpEstablishError {}
    

    I also have some incomplete jasmine tests for it (Just a note on the test: I have a concern the test may slow down your test suite, so use with caution!):

    describe('Utilities', () => {
      describe('retryWithBackoff should', () => {
        it('return success observable after failing max-1 times', (done) => {
          const source = (count, maxRetries) => {
            return defer(() => {
              if (count <= maxRetries) {
                count++;
                return throwError(true);
              } else {
                return of(true);
              }
            });
          };
          source(1, 5 - 1).pipe(retryWithBackoff(1, 5)).subscribe(
            (value) => {
              expect(value).toBe(true);
            },
            () => {
              fail();
              done();
            },
            () => {
              done();
            }
          );
        });
    
        it('raise HttpTimeoutError if maxTime is reached', (done) => {
          const maxTime = 1000;
          const source = new Subject<any>();
          source.pipe(retryWithBackoff(1000, 5, maxTime)).subscribe(
            () => {
              fail('should not happen');
            },
            (err) => {
              expect(err).toBeInstanceOf(HttpTimeoutError);
              done();
            }
          );
        });
      });
    
      it('raise HttpMaxRetriesError is maxRetries is reached', (done) => {
        const source = (count, maxRetries) => {
          return defer(() => {
            if (count <= maxRetries) {
              count++;
              return throwError(true);
            } else {
              return of(true);
            }
          });
        };
        source(1, 5 + 1).pipe(retryWithBackoff(1, 5)).subscribe(
          () => {
          },
          (err) => {
            expect(err).toBeInstanceOf(HttpMaxRetriesError);
            done();
          },
        );
      });
    });