Michele Stieven
Posted on July 30, 2023
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;
...
}
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)
);
}
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
}
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)
);
}
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)
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(...))
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))
Or, if you don't want to use the of
operator, you could wrap it in an array:
catchError(error => [value]);
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,
});
}
}
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;
});
}
}
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(() => []),
);
}
}
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;
});
}
}
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)
}),
);
}
}
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)
})
)
}
You can then provide the interceptor globally like this:
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(
withInterceptors([logInterceptor])
)
]
})
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');
}
}
})
)
}
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)])
))
));
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),
...
);
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);
}
}
And provide it globally:
providers: [{
provide: ErrorHandler,
useClass: CustomErrorHandler
}]
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
Posted on July 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.