Error handlingBasics · Hono

Throw HTTPException where a problem is found, and give the whole API one shape for errors with app.onError() and app.notFound(), including what a crash looks like from outside.

What you will learn

Read the theory for Error handling

All Basics lessons

All Hono courses

loading types…

What you'll learn

  • Throw new HTTPException(status, { message }) from anywhere a request goes wrong, instead of returning error responses by hand
  • Turn every error into one JSON shape in app.onError(), and keep a plain Error a 500 that hides its message
  • Answer unknown routes with the same shape from app.notFound()

Error handling

By now the cats API answers a missing cat with c.json({ error: … }, 404) in three places, each written by hand, each free to drift from the others. A client that reads the errors needs one shape, and a route that finds a problem three calls deep needs a way to stop that does not thread a Response back up through every return. Both are what exceptions are for. Hono's HTTPException carries a status and a message, app.onError() decides once what any error looks like on the wire, and app.notFound() does the same for a request no route claims.

Throwing

import { HTTPException } from 'hono/http-exception';

function findUser(id: string) {
  const user = users.get(id);
  if (!user) throw new HTTPException(404, { message: `User ${id} not found` });
  return user;
}

app.get('/users/:id', (c) => c.json(findUser(c.req.param('id'))));

new HTTPException(status, { message }) is an ordinary Error with a status field. Thrown from a handler, from a middleware, or from a helper the handler called, it unwinds to Hono, which catches it. The handler above has no error branch, and neither does anything else that calls findUser. The status type is ContentfulStatusCode, so new HTTPException(204) is a type error: an error response has a body.

Without any handler of your own, Hono answers an HTTPException with its status and its message as plain text, Unauthorized for a 401, and answers any other thrown Error with 500 and the text Internal Server Error, after printing the error to the console. A thrown Error you did not plan for therefore never leaks its message to the client; that is the right default and worth keeping.

Shaping every error once

app.onError() receives the error and the context, and returns the response:

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return c.json({ error: err.message, status: err.status }, err.status);
  }
  console.error(err);
  return c.json({ error: 'Internal Server Error', status: 500 }, 500);
});

Two cases, kept apart by instanceof. An HTTPException is expected: its status and message are meant for the client. Anything else is a bug or an outage, and the client gets a generic 500 while the console gets the stack; in production that log line is what you would look at. err.status is already typed as a status code, so it can be passed straight to c.json().

An HTTPException can also carry a ready-made response, new HTTPException(401, { res }), for the cases where the error needs its own headers, and err.getResponse() gives it back. Note the docs' warning that getResponse() knows nothing about the context, so headers set with c.header() earlier are not on it.

Unknown routes

A request no route matches never reaches a handler, so it never throws; Hono answers 404 Not Found as text. app.notFound() replaces that:

app.notFound((c) => c.json({ error: `Route ${c.req.method} ${c.req.path} not found`, status: 404 }, 404));

It receives only the context. c.notFound() inside a handler answers with the same function, for a handler that decides mid-way that there is nothing here.

Where it sits

The chain from lesson 4 is still the whole picture. A throw anywhere in it stops the chain at that point; onError runs in place of whatever would have answered, and the middleware registered above still gets its turn after next() resolves, with c.res being the error response. Two limits: a handler that returns a non-Error value, or throws one, is not something Hono handles; and a stream that fails after its headers were sent cannot be turned into an error response any more, which lesson 9 comes back to.

Your task

The API answers errors three different ways. Give it one:

  1. Make findCat(id) return the cat or throw HTTPException(404) with the message Cat <id> not found, and let GET /cats/:id and DELETE /cats/:id use it without an if.
  2. DELETE /cats/:id on a cat whose protected is true throws HTTPException(403) with <name> cannot be removed.
  3. app.onError() answers every HTTPException as { "error": message, "status": status } with that status, and anything else as { "error": "Internal Server Error", "status": 500 } with 500, after logging it.
  4. app.notFound() answers { "error": "Route <METHOD> <path> not found", "status": 404 }.

GET /crash throws a plain Error on purpose. Run after step 3 and read the console: the message is there, and the response does not carry it.

When it fails

  • GET /cats/99 answers the text Cat 99 not found with no JSON: the exception is thrown but app.onError() is missing, so Hono's default answered.
  • GET /crash answers { "error": "the cat database is on fire", "status": 500 }: the onError handler used err.message for every error. Only an HTTPException carries a message meant for clients.
  • GET /dogs answers 404 Not Found as text: app.notFound() is missing, or was written as a route, app.get('*', …), which also works but must come last.
  • findCat returns Cat | undefined and the handler does not compile: the helper still returns undefined on the missing path. Throw there instead, and the return type narrows to Cat.

Remember

  • throw new HTTPException(status, { message }) anywhere in the chain; Hono catches it.
  • app.onError((err, c) => …) shapes every error once; err instanceof HTTPException separates expected errors from bugs, and a bug's message stays in the console.
  • app.notFound((c) => …) answers routes nothing matched; c.notFound() calls it from a handler.
  • The default is sane: a plain Error is a 500 that leaks nothing.
Stuck? Show a hint

A helper that looks a cat up and throws new HTTPException(404, { message: `Cat ${id} not found` }) when it is missing lets every route use it without an if. In app.onError((err, c) => …), err instanceof HTTPException tells the two cases apart, and err.status is the code to answer with. app.notFound((c) => …) receives the context, so c.req.method and c.req.path can name the route.