How we automated displaying error pages based on API responses

Farhan CK

By Farhan CK

on May 28, 2024

Error handling is an important aspect of building software. We've automated error handling wherever possible so that we can deal with it in a consistent manner. By automating this process we've allowed our product engineers to focus on shipping quality software.

This blog is about how we automated handling and displaying error pages in neeto applications.

Before we jump in, let's look at Axios and Zustand, two npm packages we heavily rely on.

Axios

We use Axios to make API calls. Axios allow us to intercept API calls and modify them if necessary.

1axios.interceptors.request.use(request => {
2  // intercept requests
3});
4axios.interceptors.response.use(request => {
5  // intercept responses
6});

This feature is useful for tasks such as setting authentication tokens, handling case conversion for requests and responses, cleaning up sensitive headers when sending requests to third-party domains, etc. This is also a good place to universally handle any errors on API calls.

Zustand

Zustand is a small, fast, scalable, barebones state management solution using simplified flux principles. We use Zustand for state management instead of Redux/React context. Here is a simple example of how to create a store using Zustand.

1import { create } from "zustand";
2
3const useBearStore = create(set => ({
4  bears: 0,
5  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
6  removeAllBears: () => set({ bears: 0 }),
7}));

To learn more about Zustand, check out there documentation.

Detecting errors

Let's look into how we handle errors in our applications. As a first step, let's examine the universal Axios response interceptor we wrote to detect any error.

1import axios from "axios";
2
3axios.interceptors.response.use(null, error => {
4  if (error.response?.status === 401) {
5    resetAuthTokens();
6    window.location.href = `/login?redirect_uri=${encodeURIComponent(
7      window.location.href
8    )}`;
9  } else {
10    const fullUrl = error.request?.responseURL || error.config.url;
11    const status = error.response?.status;
12    //TODO: Notify the user that an error is occurred.
13  }
14  return Promise.reject(error);
15});

For each response, if it detects a 401 HTTP error, the code resets the authentication tokens and redirects the user to the login page. For any other error, we need to notify the user with an appropriate message.

Storing errors

When an error happens, the user could be on any page, so we should be able to display an error message no matter which page the user is on. For that, we need to store the error in a way that it can be retrieved from anywhere. This is where Zustand comes into play. It is very easy to create a store in Zustand and can be used as a hook in React applications.

1import { prop } from "ramda";
2import { create } from "zustand";
3
4const useDisplayErrorPage = () => useErrorDisplayStore(prop("showErrorPage"));
5
6export const useErrorDisplayStore = create(() => ({
7  showErrorPage: false,
8  statusCode: 404,
9  failedApiUrl: "",
10}));
11
12export default useDisplayErrorPage;

The code snippet above creates a Zustand store for storing error-related data. Additionally, it provides a hook that conveniently checks whether an error has occurred anywhere within the application.

Back to our Axios interceptor, we can store the error using the useDisplayErrorPage hook.

1import axios from "axios";
2import { useErrorDisplayStore } from "./useDisplayErrorPage";
3
4axios.interceptors.response.use(null, error => {
5  if (error.response?.status === 401) {
6    resetAuthTokens();
7    window.location.href = `/login?redirect_uri=${encodeURIComponent(
8      window.location.href
9    )}`;
10  } else {
11    const fullUrl = error.request?.responseURL || error.config.url;
12    const status = error.response?.status;
13    useErrorDisplayStore.setState({
14      showErrorPage: true,
15      statusCode: status,
16      failedApiUrl: fullUrl,
17    });
18  }
19  return Promise.reject(error);
20});

Display errors

Now, we can use the useDisplayErrorPage hook at the root of our React application. When an error happens, showErrorPage will become true; we can use that to show an error page.

1import useDisplayErrorPage from "./useDisplayErrorPage";
2import ErrorPage from "./ErrorPage";
3const Main = () => {
4  const showErrorPage = useDisplayErrorPage();
5  if (showErrorPage) {
6    return <ErrorPage />;
7  }
8
9  return <>Our App</>;
10};

Let's look at the ErrorPage component. The component reads the error data from the Zustand stores and displays the appropriate error message and the picture.

1import { useErrorDisplayStore } from "./useDisplayErrorPage";
2import { shallow } from "zustand/shallow";
3
4const ERRORS = {
5  404: {
6    imageSrc: "not-found.png",
7    errorMsg: "The page you're looking for can't be found.",
8    title: "Page not found",
9  },
10  403: {
11    imageSrc: "unauthorized.png",
12    errorMsg: "You don't have permission to access this page.",
13    title: "Unauthorized",
14  },
15  500: {
16    imageSrc: "server-error.png",
17    errorMsg:
18      "The server encountered an error and could not complete your request.",
19    title: "Internal server error",
20  },
21};
22
23const ErrorPage = ({ statusCode }) => {
24  const { storeStatusCode, showErrorPage } = useErrorDisplayStore(
25    pick(["statusCode", "showErrorPage"]),
26    shallow
27  );
28  const status = statusCode || storeStatusCode;
29  const { imageSrc, errorMsg, title } = ERRORS[status] || ERRORS[404];
30
31  return (
32    <div className="flex flex-col items-center justify-center h-screen">
33      <title>{title}</title>
34      <img src={imageSrc} className="mb-4" alt="Error Image" />
35      <div className="text-lg font-medium">{errorMsg}</div>
36    </div>
37  );
38};

Custom error pages in Rails

Ruby on Rails comes with default error pages for commonly encountered requests such as 404, 500, and 422 errors. Each request has an associated static HTML page located within the public directory. Even though we can customize them to look like our ErrorPage component, maintaining error pages in different places will be difficult, and in the long run, they can go out of sync. So, we decide to use the ErrorPage component for these scenarios as well.

To accomplish this, we'll start by creating a controller named ErrorsController. This controller will contain a single show action, where we'll extract the error code from the raised exception and render the appropriate view.

1class ErrorsController < ApplicationController
2  before_action :set_default_format
3
4  def show
5    @status_code = params[:status_code] || "404"
6    error = @status_code == "404" ?  "Not Found" : "Something went wrong!"
7
8    if params[:url]
9      Rails.logger.warn "ActionController::RoutingError (No route matches [#{request.method}] /#{params[:url]})"
10    end
11
12    respond_to do |format|
13      format.json { render json: { error: }, status: @status_code }
14      format.any { render status: @status_code }
15    end
16  end
17
18  private
19
20    def set_default_format
21      request.format = :html unless request.format == :json
22    end
23end
24

Next, we'll create a view file for this show action. In this view file, we will render the Error component and pass error_status_code to it as props.

1<%= react_component("Error", { error_status_code: @status_code }, { class: "root-container" }) %>

In the Error component, we will render the same ErrorPage component created above and pass the error status code received.

1import React from "react";
2import ErrorPage from "./ErrorPage";
3
4const reactProps = JSON.parse(
5  document.getElementsByClassName("root-container")[0]?.dataset?.reactProps ||
6    "{}"
7);
8
9const Error = () => <ErrorPage status={reactProps.error_status_code} />;
10
11export default Error;

Now add the following route in the config/routes.rb file to point 404 and 500 requests to the show action in the ErrorsController.

1Rails.application.routes.draw do
2  match "/:status_code", constraints: { status_code: /404|500/, format: :html }, to: "errors#show", via: :all
3end

Finally, we need to tell the Rails application to use our new controller and routes setup instead of those HTML templates in the public directory. For this, add the following to the class Application < Rails::Application block in config/application.rb file.

1config.exceptions_app = self.routes

Handling unmatched routes

Sometimes, we get random requests from bots and crawlers to access the paths that don't exist. When this occurs, our server raises an ActionController::RoutingError exception. This also happens when a user mistypes a URL. However, In such cases, instead of just throwing the exception, we should inform the user that what they are requesting does not exist by showing a 404 page.

To handle this, we implemented a catch_all route, as the name suggests, which catches any routes that don't match already defined routes, which will be placed at the end of our routes configuration. This placement ensures that it is only called if a request doesn't match any other defined route.

1Rails.application.routes.draw do
2  #Define other routes here...
3
4  #Catch-all route for handling errors
5  match ":url", to: "errors#show", via: :all, url: /.*/, constraints: -> (request) do
6    request.path.exclude?("/rails/")
7  end
8end

This route also uses the same ErrorsController mentioned above for handling unmatched requests and will show a 404 page.

Stay up to date with our blogs. Sign up for our newsletter.

We write about Ruby on Rails, ReactJS, React Native, remote work,open source, engineering & design.