How we automated displaying error pages based on API responses

Farhan CK

Farhan CK

May 28, 2024

How we automated displaying error pages based on API responses

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.

axios.interceptors.request.use(request => {
  // intercept requests
});
axios.interceptors.response.use(request => {
  // intercept responses
});

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.

import { create } from "zustand";

const useBearStore = create(set => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

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.

import axios from "axios";

axios.interceptors.response.use(null, error => {
  if (error.response?.status === 401) {
    resetAuthTokens();
    window.location.href = `/login?redirect_uri=${encodeURIComponent(
      window.location.href
    )}`;
  } else {
    const fullUrl = error.request?.responseURL || error.config.url;
    const status = error.response?.status;
    //TODO: Notify the user that an error is occurred.
  }
  return Promise.reject(error);
});

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.

import { prop } from "ramda";
import { create } from "zustand";

const useDisplayErrorPage = () => useErrorDisplayStore(prop("showErrorPage"));

export const useErrorDisplayStore = create(() => ({
  showErrorPage: false,
  statusCode: 404,
  failedApiUrl: "",
}));

export 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.

import axios from "axios";
import { useErrorDisplayStore } from "./useDisplayErrorPage";

axios.interceptors.response.use(null, error => {
  if (error.response?.status === 401) {
    resetAuthTokens();
    window.location.href = `/login?redirect_uri=${encodeURIComponent(
      window.location.href
    )}`;
  } else {
    const fullUrl = error.request?.responseURL || error.config.url;
    const status = error.response?.status;
    useErrorDisplayStore.setState({
      showErrorPage: true,
      statusCode: status,
      failedApiUrl: fullUrl,
    });
  }
  return Promise.reject(error);
});

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.

import useDisplayErrorPage from "./useDisplayErrorPage";
import ErrorPage from "./ErrorPage";
const Main = () => {
  const showErrorPage = useDisplayErrorPage();
  if (showErrorPage) {
    return <ErrorPage />;
  }

  return <>Our App</>;
};

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.

import { useErrorDisplayStore } from "./useDisplayErrorPage";
import { shallow } from "zustand/shallow";

const ERRORS = {
  404: {
    imageSrc: "not-found.png",
    errorMsg: "The page you're looking for can't be found.",
    title: "Page not found",
  },
  403: {
    imageSrc: "unauthorized.png",
    errorMsg: "You don't have permission to access this page.",
    title: "Unauthorized",
  },
  500: {
    imageSrc: "server-error.png",
    errorMsg:
      "The server encountered an error and could not complete your request.",
    title: "Internal server error",
  },
};

const ErrorPage = ({ statusCode }) => {
  const { storeStatusCode, showErrorPage } = useErrorDisplayStore(
    pick(["statusCode", "showErrorPage"]),
    shallow
  );
  const status = statusCode || storeStatusCode;
  const { imageSrc, errorMsg, title } = ERRORS[status] || ERRORS[404];

  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <title>{title}</title>
      <img src={imageSrc} className="mb-4" alt="Error Image" />
      <div className="text-lg font-medium">{errorMsg}</div>
    </div>
  );
};

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.

class ErrorsController < ApplicationController
  before_action :set_default_format

  def show
    @status_code = params[:status_code] || "404"
    error = @status_code == "404" ?  "Not Found" : "Something went wrong!"

    if params[:url]
      Rails.logger.warn "ActionController::RoutingError (No route matches [#{request.method}] /#{params[:url]})"
    end

    respond_to do |format|
      format.json { render json: { error: }, status: @status_code }
      format.any { render status: @status_code }
    end
  end

  private

    def set_default_format
      request.format = :html unless request.format == :json
    end
end

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.

<%= 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.

import React from "react";
import ErrorPage from "./ErrorPage";

const reactProps = JSON.parse(
  document.getElementsByClassName("root-container")[0]?.dataset?.reactProps ||
    "{}"
);

const Error = () => <ErrorPage status={reactProps.error_status_code} />;

export 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.

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

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.

config.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.

Rails.application.routes.draw do
  #Define other routes here...

  #Catch-all route for handling errors
  match ":url", to: "errors#show", via: :all, url: /.*/, constraints: -> (request) do
    request.path.exclude?("/rails/")
  end
end

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

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.