Error handling async requests in my full-stack application (cont'd)

Improving server-side validation and making effective use of middleware

Error handling async requests in my full-stack application (cont'd)

Today I cracked some troubleshooting, which ended up removing some bugs as well as introducing some pretty satisfying improvements to how my MERN stack app handles errors.

In yesterday’s post I mentioned using React Router's error elements, but I found that these weren't quite working as intended, because of how my app's backend was validating data fetch requests. I was testing for errors at the route level - each item's 'show' page is linked to an id which is prepended to the URL, e.g: http://localhost:5173/bathrooms/66c60dffeb5f2defee8b2615 - and used to fetch from the database, so navigating to the wrong URL (which will in turn trigger an incorrect db query) should return a fallback error element.

Diagnosing the issue

However, when manually testing the app, I identified 2 related issues, which can be broken down as follows:

  1. When navigating to a URL using an id that doesn't exist in my db, my React Router 'not found' error element simply wasn't displaying - instead I was getting a blank layout page with no user feedback:

    React Router error element failing to display

  2. If the pattern of id in my URL didn't match the pattern above, I was returning a BSON / CastError in Mongoose, because the id is expected to be in 24-character hex string format. As a result I would see my custom React Router 'not found' page, but then my backend would crash - not ideal!

Once I had defined these issues clearly and gathered more information, I was able to tackle them in sequence.

Solving issue #1 - React Router error elements not displaying

To solve this issue, I simply needed to improve how I handled Promises in my data loader. Originally it looked like this:

export default async function showLoader({ params }: LoaderFunctionArgs) {
  const { id } = params;
  const res = await fetch(http://localhost:8000/bathrooms/${id});
  if (!res.ok) {
    throw Error("Failed to fetch bathroom data");
  }
  return res.json();
}

On closer inspection, the response I'm collecting here isn't necessarily a resolved Promise, so a pending Promise will pass the validation and not throw an error. This is what causes the blank content, and failure to display an error element.

I therefore needed to parse the data and then add an extra validation check:

export default async function showLoader({ params }: LoaderFunctionArgs) {
  const { id } = params;
  const res = await fetch(`http://localhost:8000/bathrooms/${id}`);
  if (!res.ok) {
    throw Error("Failed to fetch bathroom data");
  }
  const bathroom = await res.json();
  if (!bathroom) {
    throw Error("Failed to fetch bathroom data");
  }
  return bathroom;
}

By awaiting res.json() before adding another validation check, I can ensure that the Promise is resolved before returning the result from the loader. If nothing is returned, an error is thrown, and I now get the ‘not found’ error element as expected.

Solving issue #2 - Mongoose validation errors

This one is more critical for the functioning of the app, and again requires some extra validation on my routes to ensure that the id is in the expected format. There is a quick and easy solution to this, but I wanted to handle this more elegantly, so I refined this later. I’ll cover both:

The simple approach

This is the simplest way to ensure that the id passed in to my route/controller doesn’t trigger a Mongoose validation error.

Previously I used a very basic Mongo database query in my fetch request, but hadn’t yet covered proper error handling, such as cases where the incorrect format of id might be passed in:

const showBathroom = async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  const bathroom = await Bathroom.findById(id);
  res.json(bathroom);
}

This can be resolved by applying a few additional validation checks on my route, including a try...catch block where the error is passed to my error handling middleware:

const showBathroom = async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  // Validate if the ID is a valid MongoDB ObjectId
  if (!mongoose.Types.ObjectId.isValid(id)) {
    // If ID is not valid, return a 400 Bad Request response
    return res.status(400).json({ message: "Invalid bathroom ID format" });
  }
  try {
    // Query the database with the validated ID
    const bathroom = await Bathroom.findById(id);
    // If no bathroom is found, return a 404 Not Found response
    if (!bathroom) {
      return res.status(404).json({ message: "Bathroom not found" });
    }
    // If bathroom is found, return it in the response
    res.json(bathroom);
  } catch (err) {
    // If any other error occurs (e.g., DB issue), pass it to the error handler
    next(err);
  }
};
export default showBathroom;

Handling async requests with middleware

There is a more elegant approach here, which is in some ways actually less complex, and helps to avoid repetition across routes which handle fetching data (and with this, the potential to miss key validation checks). This involves reusable middleware.

As hinted earlier, I already have some custom middleware setup for my error handling:

const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const statusCode = res.statusCode ? res.statusCode : 500;
  const errObj = new AppError(err.message || "Server Error", statusCode);
  res.json({
    message: errObj.message,
    statusCode: errObj.status,
    stack: process.env.NODE_ENV === "production" ? null : errObj.stack,
  });
};

export default errorHandler;
// index.ts
app.use(errorHandler);

Now instead of the try...catch above, I can replace this with some middleware to handle async requests. Then, if meeting the ‘catch’ exception, passing the error via next() to my error handling middeware:

export default function wrapAsync(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
  return function (req: Request, res: Response, next: NextFunction) {
    fn(req, res, next).catch((e) => next(e));
  };
}

I can then use this to wrap my controllers which use async requests:

router
  .route("/")
  .get(wrapAsync(bathroomController.showBathroomsIndex))
  .post(wrapAsync(bathroomController.createBathroom));

router.route("/seedDB").post(seedDB);

router.route("/:id").get(wrapAsync(bathroomController.showBathroom));

Ultimately this is quite simple, but the result is some very effective handling of async requests, and most importantly - no crashes!

Takeaways

This all shows the importance of graceful error handling across all parts of your application - it's important to be really intentional about this, to avoid unexpected consequences further down the line.

It’s also very useful to take opportunities to employ middleware effectively, for overall error handling and especially validating async requests. This can greatly improve the development experience by providing reusable code, making an app more maintainable as well as making your routes more robust.

Some great experience and lessons to take forward here.