DEV Community

Cover image for Error Handling with Angular Interceptors
Cezar Pleșcan
Cezar Pleșcan

Posted on • Edited on

18

Error Handling with Angular Interceptors

Introduction

In this article I'll tackle the challenge of building a robust error handling in our user profile form application. I'll look beyond simple validation errors and dive into a wider array of issues that can arise during the HTTP communication with the backend server. What if there's no network connection, or the server sends us an unexpected response? To ensure a smooth user experience, we need to anticipate these errors and provide clear feedback or recovery options.

What I'll cover

  • The need for comprehensive error handling - look beyond validation errors and uncover into the different types of issues that can occur during HTTP communication.
  • The power of interceptors - discover how interceptors can act as a central point for managing errors, validating responses, and enhancing security.
  • Creating and registering an interceptor - the process of setting up an Angular interceptor.
  • Validating successful responses - implement the logic to ensure that the server's 200 OK responses match our expected format.
  • Handling network errors - learn how to detect and manage scenarios where the user loses internet connection.
  • Tackling other errors - explore strategies for handling server-side errors and unexpected issues.

A quick note

  • This article builds upon the concepts and code I've developed in previous articles in this series. If you're just joining us, I highly recommend catching up on the earlier articles to make the most of this one.
  • You can find the code I'll be working with in the 16.user-service branch of the repository.

Identifying the current issues

So far, I've focused on handling form validation errors within the tapValidationErrors operator - those 400 Bad Request responses from the server when the form data isn't quite right. However, there are other types of errors that can crop up, and we need a way to deal with them too. These include:

  • network errors - the "no internet connection" scenario.
  • invalid response formats - even if the server responds with a 200 status code, the data might not be in the format we expect.
  • unexpected errors - the server could return various error codes, such as 4xx or 5xx, but other than the 400 Bad Request, which I already handled in the tapValidationErrors RxJS operator.

Current error handling limitations

Currently, error handling is primarily managed by the UserProfileComponent, using the tapError operator to set an error flag or display a popup message. Additionally, the tapResponseData operator assumes the response will always be in the expected successful format. We need to expand our error-handling capabilities to cover unexpected scenarios and responses with invalid formats.

Introducing Angular interceptors

That's where Angular HTTP interceptors come into play. These handy tools let us intercept and handle HTTP requests and responses, giving us greater control over how our application communicates with the backend.

They allow us to:

  • Catch errors globally - Instead of handling errors in every component, we can catch them in one place.
  • Validate response formats - We can verify that server responses match our agreed-upon structure.
  • Handle specific error types - We can differentiate between various error scenarios (e.g., network errors, authorization errors, server errors) and respond appropriately.
  • Enhance security - We can add headers, tokens, or other security measures to requests and responses.

Creating and registering an interceptor

To get started, I'll create an interceptor in the src/app/core/interceptors folder using the Angular CLI command ng generate interceptor server-error. This will generate a file named server-error.interceptor.ts:

import { HttpInterceptorFn } from '@angular/common/http';
export const serverErrorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req);
};

Next, I need to tell Angular to use this interceptor whenever we make HTTP requests with the HttpClient service. More specifically, I need to update the app.config.ts file:

export const appConfig: ApplicationConfig = {
providers: [
// the rest of the providers ...
provideHttpClient(withInterceptors([serverErrorInterceptor])),
]
};
view raw app.config.ts hosted with ❤ by GitHub

At this point, our interceptor doesn't do anything yet. My first task will be to validate the format of successful responses, which have the HTTP "200 OK" status code.

Validating successful response format

Recall that in the tapResponseData operator definition I've assumed that successful responses from the server follow a specific format.

export function tapResponseData<T extends ApiSuccessResponse<any>>(callback: (data: T['data']) => void) {
return tap((value: HttpClientResponse<T>) => {
if (isResponseInstance<T>(value as HttpResponse<T>)) {
callback((value as HttpResponse<T>).body!.data);
}
else if (isPlainResponse<T>(value as T)) {
callback((value as T).data);
}
});
}

Let's recap that format defined within the server.ts file. It's an object of this type {status: 'ok', data: any}:

// API endpoint to get all users
app.get('/users', (req, res) => {
const users = loadDb();
res.json({
status: 'ok',
data: users
});
});
view raw server.ts hosted with ❤ by GitHub

It's important to note that there is no single, universal standard for RESTful API response formats. Each application can have its own conventions. However, once a format is established, the client (our Angular app) should verify if the server's response complies with it. This helps catch unexpected errors or inconsistencies on the backend.

Implementing the response format validation

Here's the updated interceptor, with the check for the format of 200 OK responses:

/**
* Custom error class representing an invalid response body format.
*/
class HttpResponseBodyFormatError extends Error {
constructor() {
super('Invalid response body format')
}
}
/**
* HttpInterceptor function that intercepts and validates the format of successful (200 OK) responses.
* If the response doesn't match the expected format, it throws an HttpResponseBodyFormatError
* and displays an error notification to the user.
*
* @param req The intercepted HttpRequest.
* @param next The HttpHandler to pass the request to.
* @returns An Observable of HttpEvent<any>.
*/
export const serverErrorInterceptor: HttpInterceptorFn = (req, next) => {
// Inject the MatSnackBar service for displaying error notifications.
const snackbar = inject(MatSnackBar);
// Continue handling the request and intercept the response.
return next(req).pipe(
tap((httpEvent) => {
// Checks if the HttpEvent is a successful (200 OK) HttpResponse,
// but has an invalid response body format.
if (checkInvalid200Response(httpEvent)) {
// If the response doesn't match the expected format, notify the user.
snackbar.open('An internal error has occurred. Please try again later.');
const error = new HttpResponseBodyFormatError();
// Log a warning message to the console with details of the error and the invalid response.
// This can aid in debugging and identifying issues with the backend API.
console.warn(error)
// Throws a custom HttpResponseBodyFormatError to signal the invalid response format.
// This error will be caught and handled by a higher-level error handler in the application,
// enabling appropriate error recovery or logging mechanisms.
throw error;
}
})
);
};
/**
* Helper function to check if an HttpEvent is a successful 200 OK response
* but with an unexpected body format.
*
* @param httpEvent The HttpEvent to be checked.
* @returns True if it's a 200 OK HttpResponse that fails the body format check, false otherwise.
*/
function checkInvalid200Response(httpEvent: HttpEvent<any>): boolean {
return (
// Must be an instance of HttpResponse (i.e., a response, not a request or other event)
httpEvent instanceof HttpResponse
// Must have a successful status code (200 OK)
&& httpEvent.status === HttpStatusCode.Ok
// But the body format must be invalid
&& !check200ResponseBodyFormat(httpEvent)
)
}
/**
* Verifies if the response body adheres to the expected format for a successful (200 OK) response.
* The expected format is a plain object with a 'status' property of 'ok' and a defined 'data' property.
*
* @param response The HttpResponse to check.
* @returns True if the response body matches the expected format, false otherwise.
*/
function check200ResponseBodyFormat(response: HttpResponse<any>): boolean {
return isPlainObject(response.body)
&& response.body.status === 'ok'
&& response.body.data !== undefined
}

The check200ResponseBodyFormat function verifies if a response matches the expected format. The interceptor taps into the HTTP response stream, checking if the response is a 200 OK and if it fails the format check. If so, it displays an error notification using MatSnackBar and throws a custom error.

To see this in action, you can intentionally modify the server.ts file to return a malformed response with a 200 status code (e.g., change status: 'ok' to status: 'bad format'). Then, restart the server and reload the application. The interceptor should detect this error and display the notification.

Check out the updated code

The updated code incorporating the changes made so far can be found in the repository at this specific revision. Feel free to explore the repository to see the full implementation details of the interceptor.

Handling network errors

What happens when the user loses internet connection? This is a common scenario that we need to handle gracefully to provide a good user experience. To catch network errors, I'll leverage the catchError operator within the interceptor. This operator allows us to intercept errors in the HTTP request/response pipeline and take appropriate action.

Implementation

Here's how I'll modify the interceptor:

/**
* Custom error class for handling network connection errors (e.g., no internet).
* Includes a `wasCaught` flag to track whether the error was already handled by the interceptor.
*/
class HttpNoNetworkConnectionError extends Error {
// Flag to indicate if this error has been handled
wasCaught = false;
constructor() {
super('No network connection');
}
}
/**
* HTTP interceptor function that intercepts and validates HTTP responses.
*
* It performs the following checks:
* 1. Verifies if a 200 OK response has a valid format.
* 2. Detects potential network connection errors.
* 3. Re-throws other errors for higher-level handling.
*
* @param req - The intercepted HttpRequest.
* @param next - The HttpHandler to pass the request to.
* @returns An Observable of HttpEvent<any>.
*/
export const serverErrorInterceptor: HttpInterceptorFn = (req, next) => {
// Inject the MatSnackBar service for displaying error notifications.
const snackbar = inject(MatSnackBar);
// Continue handling the request and intercept the response.
return next(req).pipe(
tap(...),
catchError(error => {
// Handle network connection errors specifically
if (checkNoNetworkConnection(error)) {
// Show a user-friendly message
snackbar.open('No network connection. Please try again later.');
// Create a custom network error
const error = new HttpNoNetworkConnectionError();
// Log the error for debugging purposes
console.warn(error);
// Mark the error as caught to prevent duplicate handling
error.wasCaught = true;
// Re-throw the (modified) network error to allow for potential additional handling
// in the component or other error handlers further down the line.
throw error;
}
// For all other types of errors (e.g., server errors, timeouts),
// simply re-throw the error so that it can be handled by a higher-level error handler
// (e.g., a global error handler or a catchError in the component).
throw error;
})
);
};
/**
* Helper function to check if an error is likely due to a network connection issue.
*
* @param error The error object to check.
* @returns `true` if it's likely a network error, `false` otherwise.
*/
function checkNoNetworkConnection(error: any): boolean {
return(
error instanceof HttpErrorResponse
&& !error.headers.keys().length
&& !error.ok
&& !error.status
&& !error.error.loaded
&& !error.error.total
)
}

Remember that network error detection can be tricky, and this implementation is just one approach. Depending on your application's specific needs, you might need to adjust or expand this logic further.

How it works

  1. The catchError operator intercepts any errors that happen during the request.
  2. The checkNoNetworkConnection function checks if the error looks like a network issue. This function examines the error object for missing headers, a zero status code, and other clues.
  3. If it's a network error:
    • Show a friendly message to the user ("No network connection").
    • Log the error so we know it happened (for debugging).
    • Set a flag wasCaught on the error to remember that the interceptor already handled it.
    • Re-throw the error. This is important! It lets other parts of the app know about the problem. For example, the tapError operator I created earlier can now use that wasCaught flag to avoid showing the same message twice.
  4. If it's not a network error, I just re-throw it, letting other parts of the app deal with it in their own way.

Updating the tapError operator

To ensure that we don't display multiple error notifications for the same error, I'll update the tapError operator to check the flag wasCaught on the error object. This flag is set by the interceptor when it catches a network error.

Here is the updated operator:

export function tapError(callback: (error: HttpErrorResponse, wasCaught?: boolean) => void) {
/**
* Catches errors emitted by the source observable.
*
* @param error The HttpErrorResponse object containing details about the HTTP error.
* @returns The EMPTY observable, which immediately completes the stream without emitting any values.
*/
return catchError((error: HttpErrorResponse) => {
// Retrieve the 'wasCaught' flag from the error object if it exists, otherwise default to 'false'
const wasCaught = Reflect.get(error, 'wasCaught') || false;
// Invoke the callback with the error and the 'wasCaught' flag
callback(error, wasCaught);
// Returns EMPTY to complete the observable stream.
// This prevents any further values from being emitted after the error has been handled.
// This is useful for scenarios where we want to stop processing after an error occurs (e.g., in HTTP requests).
return EMPTY;
})
}
view raw tap-error.ts hosted with ❤ by GitHub

Then, in the UserProfileComponent, I have to update the request stream pipeline in the saveUserData() method where the operator is used:

/**
* Taps into the observable stream to handle errors.
*
* @param error The HttpErrorResponse object containing details about the HTTP error.
* @param wasCaught A flag indicating whether the error was already handled by the interceptor.
*/
tapError((_, wasCaught) => {
// Display a notification for unhandled errors, ensuring that errors already
// handled by the interceptor (like network errors) are not displayed again.
if (!wasCaught) {
this.notification.display('An unexpected error has occurred. Please try again later.')
}
})

Check out the updated code

The updated code with these changes can be found in the repository at this specific revision.

Handling other error types

While the interceptor manages invalid 200 responses and network issues, other error scenarios can still arise during server communication. For a robust user experience, I need to address these remaining errors as well.

Implementation

I'll enhance the existing interceptor code to handle these additional errors:

class HttpResponseBodyFormatError extends Error {...}
class HttpNoNetworkConnectionError extends Error {...}
/**
* Constant object containing error messages for display in the UI.
*/
const MESSAGES = {
INTERNAL_ERROR: 'An internal error has occurred. Please try again later.',
NO_CONNECTION: 'No network connection. Please try again later.'
}
/**
* HTTP interceptor function that intercepts and handles HTTP responses and errors.
*
* This interceptor focuses on error scenarios, performing the following tasks:
* 1. Checks if the response is a successful HTTP response (200 OK) with an invalid format.
* 2. Specifically handles network connection errors, displaying a notification to the user.
* 3. Skips explicit handling of 400 Bad Request errors, as they are expected to be validation errors
* and are typically handled by other mechanisms within the application (e.g., tapValidationErrors operator).
* 4. Re-throws other HTTP errors to be handled by downstream operators or the global error handler.
*
* @param req The intercepted HttpRequest.
* @param next The HttpHandler to pass the request to.
* @returns An Observable of HttpEvent<any>.
*/
export const serverErrorInterceptor: HttpInterceptorFn = (req, next) => {
// Inject the MatSnackBar service for displaying error notifications.
const snackbar = inject(MatSnackBar);
// Continue handling the request and intercept the response.
return next(req).pipe(
tap((httpEvent) => {
// Checks if the HttpEvent is a successful (200 OK) HttpResponse,
// but has an invalid response body format.
if (checkInvalid200Response(httpEvent)) {
// Throws a custom HttpResponseBodyFormatError to signal the invalid response format.
// This error will be caught and handled by the subsequent catchError operator,
// which will display the appropriate error message to the user.
throw new HttpResponseBodyFormatError();
}
}),
catchError(error => {
let errorMessage: string;
// Handle network connection errors specifically
if (checkNoNetworkConnection(error)) {
// Set specific message for network errors
errorMessage = MESSAGES.NO_CONNECTION;
// Create a custom network error object
error = new HttpNoNetworkConnectionError();
// Mark the error as caught to prevent duplicate handling
error.wasCaught = true;
}
else if (is400ResponseError(error)) {
// Explicitly skip handling 400 errors here (handled by tapValidationErrors operator)
// This ensures that validation errors are handled in the component,
// while other errors (e.g., 5xx, 4xx) fall through to the next case.
errorMessage = '';
}
else {
// For all other server errors or unexpected errors, display a generic error message.
errorMessage = MESSAGES.INTERNAL_ERROR
}
// Show a Snackbar notification if an error message is available.
if (errorMessage) {
snackbar.open(errorMessage);
}
// Re-throw the error for handling in the component or a global error handler.
throw error;
})
);
};
function checkInvalid200Response(httpEvent: HttpEvent<any>): boolean {...}
function check200ResponseBodyFormat(response: HttpResponse<any>): boolean {...}
function checkNoNetworkConnection(error: any): boolean {...}
/**
* Checks if the given error is a 400 Bad Request error from the server.
*
* @param error The error object to check.
* @returns True if the error is an HttpErrorResponse with status code 400 (Bad Request), false otherwise.
*/
function is400ResponseError(error: any) {
return (error instanceof HttpErrorResponse && error.status === HttpStatusCode.BadRequest);
}

While I won't cover authentication-specific errors (401, 403) in detail here (as these are typically handled by dedicated interceptors), it's important to have a strategy for dealing with unexpected server errors or other potential HTTP issues.

You might wonder, why re-throw the error after I've handled it in the interceptor? Here's the reasoning:

  • flexibility - re-throwing the error allows for additional error handling at higher levels of our application; for instance, we might have a global error handler that logs errors or sends them to an error tracking service.
  • component specific handling - our individual components might need to take specific actions based on the error; for example, our UserProfileComponent might want to display a more tailored error message in certain cases.

Skipping validation errors

One important thing to note is that I'm not going to handle validation errors in the interceptor. Why? Because I already have the tapValidationErrors operator taking care of those. This operator is designed to catch errors that are related to the data we send to the server. The interceptor will let tapValidationErrors do its thing and focus on other types of errors.

Removing tapError from the component

Remember the tapError operator I used in the saveUserData method? We don't need it anymore. Since we're catching all errors in the interceptor and showing the appropriate messages, there's no need for the component to worry about error handling.

Checking Out the Updated Code

You can find the updated code incorporating these error-handling enhancements at the following revision.

Feel free to explore the repository, experiment with the code, and see how these changes improve your Angular application robustness in handling a wider range of HTTP errors!

Further resources on Angular interceptors

While I've covered the fundamentals of error handling with Angular interceptors, there's always more to learn. If you're eager to dive deeper into this powerful tool, here are some resources that will help you level up your skills:

Wrapping Up

In this article, we've leveled up our Angular user profile form by introducing an error-handling mechanism using an HTTP interceptor. I've tackled common challenges like:

  • invalid response format - making sure the data we get from the server is what we expect, even when the status code is 200 OK.
  • network errors - handling those "no internet" moments and giving the user helpful feedback.
  • other server errors - catching and displaying messages for those unexpected server hiccups.

Check Out the Code

Ready to see it all in action? The complete code for this error-handling interceptor, along with the custom error classes and helper functions, can be found in the 17.error-interceptor branch of the GitHub repository.

Feel free to explore, experiment, and adapt it to your own Angular applications.

Thanks for reading, and happy coding!

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Retry later
Retry later