DEV Community

Cover image for Angular - Error Handling 101
Michele Stieven for This is Angular

Posted on

Angular - Error Handling 101

In an Angular app, we can handle errors in many ways, each with its pros and cons: let's explore them.

HTTP errors

The most common case of error is an HTTP call gone bad. We may act upon it in different ways: we could either react to it, replace a value, or do nothing at all. And if we want to recover, where should we do it? At the component level, in a service, in a store...?

First things first. When there's a HTTP error, Angular wraps it in a class called HttpErrorResponse: this happens both when the error is server-side (eg. a 500 error) and when the client makes a wrong request.

These are just some fields of the class:

class HttpErrorResponse {
  message: string;
  error: any | null;
  status: number;
  statusText: string;
  url: string | null;
  ...
}
Enter fullscreen mode Exit fullscreen mode

This structure is thrown as an error on the Observable you subscribe to in order to start the request. You can then use these properties to understand the error type: for example, you can look at status to check if the user's session has expired!

But what options do we have to recover from an error?

retry

Since the HttpClient service returns an Observable for each call, we can easily use RxJS operators.

You can use retry in order to repeat the call if it errors out. You can even specify a maximum number of times:

import { retry } from 'rxjs';

getPosts() {
  return this.http.get<Post[]>('/api/post').pipe(
    retry(3)
  );
}
Enter fullscreen mode Exit fullscreen mode

Instead of a number, you can supply a more powerful configuration object:

interface RetryConfig {
  count?: number
  delay?: number | ((error: any, retryCount: number) => ObservableInput<any>)
  resetOnSuccess?: boolean
}
Enter fullscreen mode Exit fullscreen mode

With this, you can even delay the re-subscription (by a number of milliseconds, or when another Observable emits something), and tell whether the error count should be reset when a value is emitted.

timeout

Speaking of HTTP requests, I cannot but recall the existence of a timeout operator, which does the opposite of what we want: it triggers an error, if the Observable doesn't emit in N milliseconds. It can come in handy together with retry or other operators.

import { timeout } from 'rxjs';

getPosts() {
  return this.http.get<Post[]>('/api/post').pipe(
    timeout(3000)
  );
}
Enter fullscreen mode Exit fullscreen mode

catchError

Another useful operator is catchError. This one may seem a bit strange at first, but that's because it's very flexible. Let me explain.

When an Observable errors out, it stops completely. It cannot emit anything else, its life ends. The only way to get new values out of it is to subscribe again, restarting its execution.

For this reason, this operator doesn't simply let you replace the error with a value: instead, it lets you switch to a new Observable in order to continue its life.

catchError(error => otherObservable)
Enter fullscreen mode Exit fullscreen mode

For example, you may want to perform an additional HTTP request (a different one, otherwise you could simply use retry):

catchError(error => this.http.get(...))
Enter fullscreen mode Exit fullscreen mode

But if this is too much, you can just return a value and that's it. The Observable will replace the error with the supplied value and then complete. You can supply the value by wrapping it in an Observable:

import { of } from 'rxjs';

catchError(error => of(value))
Enter fullscreen mode Exit fullscreen mode

Or, if you don't want to use the of operator, you could wrap it in an array:

catchError(error => [value]);
Enter fullscreen mode Exit fullscreen mode

This works because every RxJS operator which accepts an Observable also accepts arrays, promises and iterators (they're called ObservableInput). They're all converted under the hood to an Observable.

Now let's ask ourselves: where should we handle the errors?

Subscribe

The first place to consider is the simplest, inside the subscribe. This usually happens inside Components. Something like this:

export class PostComponent {

  getPosts() {
    this.http.get<Post[]>('/api/post').subscribe({
      next: posts => this.posts = posts,
      error: e => this.error = e,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

This way, we can handle the errors in a very specific way, for example showing different errors based on the HTTP call or the page that the visitor is seeing.

At the same time, it's also a bit verbose, but it obviously depends on how many errors we want to intercept.

But one thing must be clear: we're not remedying the situation! We're just detecting the error. If we wanted to recover from the error, we should use the operators we talked about earlier, for example catchError.

export class PostComponent {

  getPosts() {
    this.http.get<Post[]>('/api/post').pipe(
      catchError(() => []),
    ).subscribe(posts => {
      this.posts = posts;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

So, always remember this important difference: when we use catchError, we don't receive the error in the subscribe anymore.

Services

If you're using Services in your app (you probably should), you may also catch the errors there, before being delivered to the Components. Something like this:

@Injectable({ providedIn: 'root' })
export class PostService {

  http = inject(HttpClient);

  getPosts() {
    return this.http.get<Post[]>('/api/post').pipe(
      catchError(() => []),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This code, however, has a problem: as we said, the Component will never know what's happened.

So, for most cases, I don't recommend this approach: as the app grows, the need to show different error messages based on the page will also grow. With this technique, we cannot do that.

Instead, consider using Services but also catching the errors at the component level:

export class PostComponent {

  getPosts() {
    this.postService.getPosts().pipe(
      catchError(() => []),
    ).subscribe(posts => {
      this.posts = posts;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

But if you also wanted to catch the errors at the Service level, you could use the tap operator to intercept the errors (but letting them be delivered to the components):

@Injectable({ providedIn: 'root' })
export class PostService {

  http = inject(HttpClient);

  getPosts() {
    return this.http.get<Post[]>('/api/post').pipe(
      tap({
        error: e => console.log(e)
      }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This is not something I'd do normally, but it may be useful in some situations.

Interceptors

You can also handle HTTP errors globally with an Interceptor. Interceptors let you do stuff before and after each HTTP request in your app. This example uses the new syntax for functional interceptors:

function logInterceptor(
  req: HttpRequest<any>,
  next: HttpHandler
): Observable<HttpEvent<any>> {
  return next.handle(req).pipe(
    tap({
      error: e => console.log(e)
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

You can then provide the interceptor globally like this:

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      withInterceptors([logInterceptor])
    ) 
  ]
})
Enter fullscreen mode Exit fullscreen mode

Be careful, though: don't catch the error, otherwise it won't be propagated to components! Instead, you may want to use this technique to handle some global state (eg. the number of ongoing requests, the total number of errors...).

You could however catch the errors which shouldn't be seen by the components, for example with retry you could hide the request gone bad to the user.

Interceptors are especially useful with 401 errors, signaling that the user's session has expired. This should cause a redirect to the login screen:

function logInterceptor(
  req: HttpRequest<any>,
  next: HttpHandler)
: Observable<HttpEvent<any>> {

  router = inject(Router);

  return next.handle(req).pipe(
    tap({
      error: e => {
        if (e instanceof HttpErrorResponse && e.status === 401) {
          this.router.navigateByUrl('/login');
        }
      }
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

Other common use-cases for interceptors are:

  • Global retry mechanism
  • Global request cache
  • Refreshing auth tokens
  • Appending Headers to requests
  • Etc...

Store

If you're using a Store, you usually put your side-effects somewhere specific: I'll make an example with NgRx.

If you're using a Store, it means that you're putting your data there instead of putting it inside the Components. This means that catching the errors inside an Effect is perfectly fine, and you may save the errors inside the Store directly.

loadPosts$ = createEffect(() => this.actions$.pipe(
  ofType(loadPosts),
  switchMap(() => this.postService.getPosts().pipe(
    map(posts => loadPostsSuccess(posts)),
    catchError(e => [loadPostsError(e)])
  ))
));
Enter fullscreen mode Exit fullscreen mode

You may also want to group different error actions together and react to multiple errors in the same place:

handleAllErrors$ = createEffect(() => this.actions$.pipe(
  ofType(loadPostsError, loadUsersError, loadTodosError),
  ...
);
Enter fullscreen mode Exit fullscreen mode

This is not something I'd suggest for the entire app (you'd be constantly updating it with dozens of actions) but for small sections of it (or pages) it should be totally fine.

ErrorHandler

Finally, if you don't want to remedy but just detect the errors, you can implement your own ErrorHandler by replacing the default one in Angular:

import { ErrorHandler } from '@angular/core';

export class CustomErrorHandler implements ErrorHandler {
  handleError(error) {
    // Do what you want here, but throw it so that it's visible on the console!
    throw new Error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

And provide it globally:

providers: [{
  provide: ErrorHandler,
  useClass: CustomErrorHandler
}]
Enter fullscreen mode Exit fullscreen mode

This class intercepts all the unhandled errors in your app (not just HTTP errors but any error), it's the final place to consider for when you can't do anything to it. It's often useful for logging (eg. sending the error to an Analytics-like server). Of course, if you catch an error anywhere in your app (eg. with catchError), it won't end up here.

Conclusions

  • Always treat your errors as part of the program, handle them whenever possible
  • If it's something that the user should see, it's usually best to catch the error at the component-level or store-level
  • Use interceptors for HTTP errors that are not tied to a specific page, or that the user shouldn't see
  • Use ErrorHandler for unhandled errors (usually bugs)

Photo by Sarah Kilian on Unsplash

Top comments (3)

Collapse
 
rensjaspers profile image
Rens Jaspers

Awesome article! I wish I had discovered the tap({error: (e)=>{}}) trick earlier. I’ve been catching and re-throwing errors for years when I could have just used tap! 😅

Collapse
 
michelestieven profile image
Michele Stieven

Same! :P Many of my notes/snippets still use catchError+throwError hehe

Collapse
 
denordd profile image
Damien Mohammed

Oui le symbolique coûte...
La crème est une souris qui mange le reste