Designing a Robust HTTP Error Handling System in Angular

yonatankorem

Yonatan Korem

Posted on October 12, 2021

Designing a Robust HTTP Error Handling System in Angular

A Story Told Over and Over

I've been a part of a number of different Angular front end projects in the past years. In every one of those projects, there came a point in time where we were asked to implement:

When a call to the backend fails due to authentication errors (401), route the user to the login page.

It didn't take long until another use case arrived:

When a call to the backend fails, alert the user of the failure with a generic "The requested operation failed".

Soon after, another use case:

When the user tries to get the information of an existing contact, and the operation fails, display a toast "Contact not found".

This would escalate further when the same operation failure would need a different error handling when done from different components.

If the user tries to edit a contact and the error is 404 (not found), then toast "Contact not found, would you like to create one?" along with a "Create" button.

I've seen, and been involved with the design decisions that attempted to handle these use cases. Here are a couple of case studies.

The Idealized

This approach tried to contextualize the error itself. An interceptor would wrap the error and give it a default message. The error would not be handled by the different layers. Instead each one could attach a new message. When a specific layer would want to "finish" handling the error, it would manually call an error handling service that would pick the appropriate message and display it.

In theory, this should have worked and support all the different use cases. It did, but it was complex. Too complex. Developers would not use it correctly, and defects would pop up. The gap between the theory described, and the practicality of it was tremendous.

Plus, it still required someone, somewhere, to handle the error. If no one does, all this mechanism does nothing.

The Simplistic

This approach went the complete opposite way. Have a simple mechanism: an interceptor would have a hard coded black list of errors it would always handle, like authentication issues that it would reroute. It also had a hard coded white list of URLs and error codes it would not handle at all.

This design was good, but it left large gaps. Any change to the URLs, any change to the possible error codes returned, would mean one of those hard coded lists would need to be manually updated. It also still didn't solve the issue with errors not being caught at all.

We Need to Get Back to Basics

If we look at the requirements fresh, we can see that the basic message of "The requested operation failed", is the message we would want to display if no one else handled the error. That means that we have to first let all components and services the opportunity to handle the error and only if none of them does, then we should display the default message.

Here lies the root of the problem with all the designs I've encountered: An interceptor is the first component that has the opportunity to handle the error, not the last one.

Introducing the ErrorHandler

Angular has a built in service called ErrorHandler. Any error that your app does not handle will reach this service. The Angular service just outputs the exception to the console. If you want to display a toast for specific unhandled errors, all you need to do is:

// my-error-handler.service.ts
class MyErrorHandler implements ErrorHandler {
  handleError(error) {
    // do something with the exception
  }
}

// app.module.ts
@NgModule({
  providers: [
    { provide: ErrorHandler, useClass: MyErrorHandler }
  ]
})
class AppModule {}
Enter fullscreen mode Exit fullscreen mode

The only difficult part here is that ALL uncaught exceptions end up here, not just HTTP ones. Luckily, we can differentiate between them with this:

if (error instanceof HttpErrorResponse) {
 // Handle HTTP errors
}
Enter fullscreen mode Exit fullscreen mode

This will cover our fallback use case so no error goes unhandled, but what about errors we want to always handle the same way?

Enter the HTTP Interceptor

While the ErrorHandler is our last line of defense, the interceptor is our first. That makes it ideal to handle the authentication errors which we would want to re-route back to a login page.

// my-interceptor.ts
class MyInterceptor implements HttpInterceptor {
   intercept(req: HttpRequest<any>,next: HttpHandler): Observable<HttpEvent<any>> {
  return next.handle(req).pipe(
    catchError(e => {
      // route if the error is an authentication error
    })
  );
}

// app.module.ts
@NgModule({
  providers: [
    { provide: ErrorHandler, useClass: MyErrorHandler },
    { provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true },
  ]
})
class AppModule {}
Enter fullscreen mode Exit fullscreen mode

For Everything in Between

We took care of the first two requirements. Let's handle this next:

When the user tries to get the information of an existing contact, and the operation fails, display a toast "Contact not found".

Our instinct might be to let the service that performed the HTTP request will handle it within the scope of the observable.

@Injectable()
export class MyService {
  constructor(private http: HttpClient) {}

  getEntity(entity: Entity): Observable<Entity> {
    return this.http.get(url).pipe(
      catchError(e => {
        // toast the appropriate message
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Which is OK until the last requirement appears:

If the user tries to edit a contact and the error is 404 (not found), then toast "Contact not found, would you like to create one?" along with a "Create" button.

We need a way for all the parties involved to have the chance to say "please toast this message" and only when everyone finished, then decide what to show.

RxJS handles all your needs

RxJS has two operators that we need to implement our solution:
CatchError and Finally.

CatchError is triggered when an error happens in the stream, and it must return another observable, or throw an error.

Finally is triggered when the stream completes, or when it errors.

The important part here is the order of them being called when the observable is constructed with multiple of both.

// serviceA
getFromBackend(): Observable<ReturnType> {
   return this.http.get(...).pipe(
      finally(() => // some service level cleanup)
   );
}

// serviceB
findElement(): Observable<ReturnType> {
   return this.serviceA.getFromBackend().pipe(
      catchError(e => {
         // log something
         throw e;
      }),
   );
}

// componentC
onButtonClick(): void {
   // set the button to disabled
   this.serviceB.findElement().pipe(
      catchError(e => of({})),
      tap(value => { 
         // do something with the value 
      }),
      finally(() => {
         // set the button back to enabled
      })
   ).subscribe();
}
Enter fullscreen mode Exit fullscreen mode

When the backend returns an error, the order of calls will be:

  1. catchError - serviceB
  2. catchError - componentC
  3. finally - serviceA
  4. finally - componentC

This is exactly what we need - anyone that wants to do something with the error does it first. Then, in a "finally" operator we could trigger the toast service. (Thank you to @elirans for the idea)

We don't want any element that wants to toast, to rely on someone else triggering the toast service. Anyone that wants to toast will need both the catchError and finally operators. Trouble is, there will be a lot of repeated code, and the risk of missing some crucial part of the behavior is high.

Because of that, we're going to create our own pipe operator!

It's really not that scary

A pipe operator is just a function that takes a stream as an input and returns a stream as an output.
In reality, most pipe operators are factory methods that return a pipe operator. We'll do just that.

// toastOnError.ts
export function toastToConsoleOnError<T>(messageToToast: string): (source: Observable<T>) => Observable<T> {
   let errorToToast: { toast: string };
   return function(source: Observable<T>): Observable<T> {
      return source.pipe(
         catchError(e => {
            e.toast = messageToToast;
            errorToToast = e;
            throw e;
         }),
         finally(() => {
            if (errorToToast && errorToToast.toast) {
               console.log(errorToToast.toast);
               errorToToast.toast = null; // since we save the reference to the error object, any future access to this field will get a null value.
            }
         })
      );
   }
}

// serviceB
findContact(searchTerm: string): Observable<Contact> {
   return this.serviceA.getFromBackend(searchTerm).pipe(
      toastToConsoleOnError('Contact not found');
   );
}

// componentC (Component level toast)
onEditRequest(): void {
   this.serviceB.findContact(this.searchTerm).pipe(
      toastToConsoleOnError('Contact not found. Would you like to create one?')
   ).subscribe();
}

// componentD (Service level toast)
onQuickViewRequest(): void {
   this.serviceB.findContact().subscribe();
}
Enter fullscreen mode Exit fullscreen mode

While the specific implementation above has its weaknesses (for example, if one element uses the operator, and another does not, you'll get two toasts), the core idea is the same and you can adjust the implementation for your needs: Maybe you need a way to mute the toasts, or maybe you want to toast if there is a condition met on the error.

With this new operator, if someone wants to toast they will, unless someone with more context also wants to toast, and we won't have two toasts popping up.

Our journey complete

We broke down the problem into three sections, and deal with each using a different mechanism:

  • Use a HTTP interceptor for handling errors that is always the same.
  • Use the Angular ErrorHandler as a failsafe to catch any error that is not handled elsewhere.
  • Create a pipe operator that uses catchError and finally to allow elements to store/overwrite the toast to display, and display it in the finally operator.
💖 💪 🙅 🚩
yonatankorem
Yonatan Korem

Posted on October 12, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related