Understanding Angular Interceptors

Interceptors are Angular services that let us introduce custom behavior into HTTP requests throughout our application. By implementing the HttpInterceptor interface, we gain the ability to hook into both outgoing requests and incoming responses. Each interceptor has full control over the request it processes.

interceptors diagram

This middle position between the application and the server is what makes interceptors so valuable. They sit between every HTTP call, giving us a centralized spot to handle concerns like authentication headers, token management, response caching, and error processing.

Defining an Error Interceptor

An error interceptor is a specialized interceptor focused on handling failures in HTTP communication. These failures can originate from the browser itself or from the server when a request cannot be completed. When the server-side request fails, HttpClient produces an error object rather than a standard response. With access to error details, we can either notify the user appropriately or, when suitable, attempt the request again.

For a broader look at interceptor usage patterns, check out this resource:

Building the Interceptor

To construct an interceptor, we define a class that implements the intercept() method from the HttpInterceptor interface:

import {Injectable} from '@angular/core';
import {HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http';
import {Observable} from 'rxjs';

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

Within intercept(), we have the opportunity to examine or modify the incoming request. The next parameter is a reference to the subsequent interceptor in the processing chain.

Registering the Interceptor

Before our ErrorInterceptor becomes operational, we need to register it as a provider in the application:

@NgModule({
  providers: [
    { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }
  ]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

With the interceptor in place, we can now enrich it with various error handling mechanisms.

Implementing a Retry Strategy

Another approach to handling failures is to re-subscribe to the Observable that emitted an error, effectively retrying the operation. This is particularly useful for transient issues like network drops in mobile environments, where a second attempt often succeeds. RxJS provides operators tailored for this purpose. The retry() operator, for instance, automatically re-subscribes a given number of times, which practically translates to re-sending the HTTP request. Here is an example of retrying a failed request:

import {Injectable} from '@angular/core';
import {HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http';
import {Observable} from 'rxjs';
import {retry} from 'rxjs/operators';

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(request)
      .pipe(retry(3)); // Retry failed request up to 3 times.
  }
}
Enter fullscreen mode Exit fullscreen mode

While this can rescue some requests, it often leads to unnecessary network traffic. Let us explore a more selective approach.

Retrying Conditionally

For a more intelligent retry mechanism, we turn to retryWhen(). This operator allows us to specify custom conditions dictating whether a retry should occur. Our smart retry policy includes the following rules:

  • Limit retries to a maximum of two
  • Only retry requests that resulted in a 500 internal server error
  • Introduce a delay before each retry attempt

These constraints give the underlying issue a chance to resolve, while avoiding retries for errors that will not benefit from another attempt.

import {Injectable} from '@angular/core';
import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
import {Observable, of, throwError} from 'rxjs';
import {mergeMap, delay, retryWhen} from 'rxjs/operators';

export const maxRetries = 2;
export const delayMs = 2000;

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      retryWhen((error) => 
        return error.pipe(
          mergeMap((error, index) => {
            if (index < maxRetries && error.status == 500) {
              return of(error).pipe(delay(delayMs));
            }

            throw error;
          })
        )
      )
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

The index supplied by mergeMap() tracks our retry count, enabling us to halt once we hit the limit. We inspect the status of the exception to apply our logic. In this case, we retry up to two times with a delay for status code 500. Any other error is re-thrown to be handled elsewhere.

For a comprehensive understanding of error handling, you might find this article useful:

Wrapping Up

This walkthrough explored multiple strategies for managing failed HTTP requests with RxJS. By employing varied retry strategies, we can precisely define the app's response to unexpected events. While it may appear to be a minor detail, shielding users from avoidable errors benefits the users, the support team, and ultimately the developers.