Error Handling

Error handling is a very important but often overlooked aspect of any application. There are multiple places in Angular where we can implement it depending on the type of error we want to handle. We can handle errors locally in components or services, or we can do it globally in interceptors. However, the best approach is to set up multiple layers in our architecture where each layer has the chance of handling exceptions. If none of them are able to resolve the error, we should fall back to the next layer until we reach the Angular ErrorHandler. By doing so, we can handle errors in a systematic manner and ensure that our application remains stable and responsive.

error-handling-1.png

Implementing Error Handling

As you can see in the diagram, we will be handling errors in 4 layers.

HttpInterceptor

This will be the outermost layer when it comes to HTTP requests

import { catchError, Observable, retry, throwError, timer } from 'rxjs'

@Injectable()
export class HttpErrorHandlerInterceptor implements HttpInterceptor {
  constructor() {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    let maxRetries = 4
    // Add a retry strategy with progressive delay between each retry
    return next.handle(req).pipe(
      retry({
        count: maxRetries,
        delay: (_, retryCount) => timer(retryCount * 500),
      }),
      catchError(error => {
        console.error(
          'From HttpErrorHandlerInterceptor',
          `${req.url} failed ${maxRetries} times.`
        )

        // Continue error handling flow when a request failed 'maxRetries' times
        return throwError(() => error)
      })
    )
  }
}

We can intercept all the HTTP requests and use RxJS to retry them when they fail.

The interceptor will act before we have a chance to handle the error in the component or service that started the chain of events, so I recommend to only add unbtrusive error handling logic, otherwise we will lose the ability of gracefully recover from errors. In this case, I will just log the error in the console and pass down the exception to the next layer.

Stores

The stores are responsible of calling methods on the http services to update the state of the application, hence, they have knownledge about the purpose of the http call that failed and what are the consequences of the error. From this we can deduct some of the actions we can take when encountering an error:

  • Create a more precise error message for the user
  • Update application state accordingly

To keep it simple I will pull the abstract methods of AbstractStore relevant to error handling into NotesStore and focus on the method getNotesEffect() as an example. Feel free to read the complete version of the code in the repository.

@Injectable()
export class NotesStore {
  private notesSubject: BehaviorSubject<Note[]> = new BehaviorSubject([])
  private statusSubject: BehaviorSubject<StoreInfo> =
    new BehaviorSubject<StoreInfo>({
      status: 'NotStarted',
      error: null,
    })
  storeStatus$ = this.statusSubject.asObservable()

  constructor(
    private notesService: NotesService,
    private toastService: ToastrService
  )

  private getNotesEffect() {
    this.notesService.getNotes()
      .subscribe({
        next: notes => {
          this.notesSubject.next(notes), this.emitStatus('Initialized', null)
        },
        error: (e: Error) => {
          // If fails, the store is set to error status until it is able to successfully retrieve data.
          this.emitStatus('Error', e)

          // The global error handler takes care of notifying the error to the user.
          // The FrienlyError class allow us to re-throw the error with a friendly message
          // while keeping the original message and stack trace intact.
          throw new FriendlyError(e, 'Something failed while loading notes.')
        },
        complete: () => {},
      })
  }


  // Shorthand for emitting new store status, passing null as status argument will leave the current value
  private emitStatus(status: StoreStatus, error: Error) {
    status ??= this.statusSubject.value.status
    this.statusSubject.next({
      status: status,
      error: error,
    })
  }
}

In this case, we will set the store status to ‘Error’ and pass an exception with a friendly error to the next layer.

Components

When handling errors in the stores we try to always keep the state in a consistent state, that way we don’t really have to worry about reacting to http errors from the components to take corrective messures. Still, if we want to do it for a special use case, we can subscribe to storeStatus$.

Other than that, the usual try catch when we feel the need should suffice for handling errors locally in the components.

Global ErrorHandler

You may have notices that when an uncaught exception is thrown, Angular logs a message into the console along with the execution stack. That is because Angular has a global error handler that wraps the application and handles all the uncaught exceptions.

Angular also allow us to provide out custom implementation for this error handler, and that is what we are going to do for the last layer.

Implementing the custom ErrorHandler

Doing it is pretty straightforward, the first thing we need to do is creating a service that implements the ErrorHandler interface

@Injectable()
export class CustomErrorHandler implements ErrorHandler {
  constructor() {}

  handleError(error: unknown): void {
  }
}

The handleError() method will be automatically called when Angular gets an unhandled error. We will add our custom logic there.

import { ErrorHandler, Injectable, Injector, NgZone } from '@angular/core'
import { ToastrService } from 'ngx-toastr'
import { FriendlyError } from './friendly-error'

@Injectable()
/**
 * Service for handling errors globally
 */
export class CustomErrorHandler implements ErrorHandler {
  constructor(private zone: NgZone, private injector: Injector) {}

  handleError(error: unknown): void {
    if (!(error instanceof Error)) return

    let message =
      error instanceof FriendlyError
        ? error.friendlyMessage
        : 'Something went wrong. For more information check the browser console.'

    // Angular creates ErrorHandler before providers otherwise it won't be able to catch errors that occurs
    // in early phase of application. Hence the providers will not be available to ErrorHandler. So, we need
    // to inject dependent services using injectors.
    let notificationService = this.injector.get(ToastrService)
    console.error('From CustomErrorHandler', error)

    // Since error handling runs outside of Angular Zone, asyncronous code will not trigger change detection
    // Therefore, any asyncronous code must be forced to run within Angular Zone
    this.zone.run(() => {
      notificationService.error(message)
    })
  }
}

In our case, we just display an error message to the user in a toast notification, and print the complete error with stack trace to the console.

Finally we need to tell Angular to use our custom class when ErrorHandler injection token is used.

import { HTTP_INTERCEPTORS } from '@angular/common/http'
import { ErrorHandler, NgModule } from '@angular/core'
import { CustomErrorHandler } from './custom-error-handler.service'
import { HttpErrorHandlerInterceptor } from './http-error-handler.interceptor'

@NgModule({
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: HttpErrorHandlerInterceptor,
      multi: true,
    },
    {
      provide: ErrorHandler,
      useClass: CustomErrorHandler,
    },
  ],
})
export class ErrorHandlingModule {}

FriendlyError

You may have noticed the class FriendlyError when we talked about the stores and the custom error handler.

    error: (e: Error) => {
        // If fails, the store is set to error status until it is able to successfully retrieve data.
        this.emitStatus('Error', e)

        // The global error handler takes care of notifying the error to the user.
        // The FrienlyError class allow us to re-throw the error with a friendly message
        // while keeping the original message and stack trace intact.
        throw new FriendlyError(e, 'Something failed while loading notes.')
    },

We would prefer the users to not see the original message, but rather a more descriptive one. The easies way would be to modify the error message before retrowing the error, but by doing that we lose the original message that we need to display into the console.

The solution is to create a class that extends from Error and has an extra property for us to set a friendly message.

export class FriendlyError extends Error {
  friendlyMessage: string

  constructor(error, friendlyMessage) {
    super()
    this.message = error.message
    this.name = error.name
    this.stack = error.stack
    this.friendlyMessage = friendlyMessage  <---
  }
}