Routes and errors

Standardize API errors

Return one useful problem-details shape for validation, missing resources, conflicts, and unexpected failures.

So far our errors have been improvised. The 404 in one route returns { title, status }, another might return { error: 'not found' }, and a thrown exception returns whatever Hono decides. A client would need a different parser for each. Let’s fix that with one shape for every error.

The shape already exists. RFC 9457 defines problem details, a small JSON object with a handful of known fields:

{
  "type": "https://example.com/problems/invalid-book",
  "title": "Book input is invalid",
  "status": 422,
  "errors": { "title": "Required" }
}

type is a URI that identifies the class of problem. It doesn’t have to resolve to a page, it just has to be stable. title is a short human summary of that class, and it should stay the same for every occurrence. status mirrors the HTTP status. detail, which I left out here, explains this specific occurrence. Anything else, like errors, is an extension field you define yourself.

The media type is application/problem+json, not plain application/json. That’s how a client knows the body is a problem and not a resource.

One helper, used everywhere

Write a single function that builds the response, and call it from every route:

import type { Context } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'

export function problem(c: Context, status: ContentfulStatusCode, title: string, extra = {}) {
  c.header('Content-Type', 'application/problem+json')
  return c.body(JSON.stringify({ type: 'about:blank', title, status, ...extra }), status)
}

Now the detail route says return problem(c, 404, 'Book not found') and the create route says return problem(c, 422, 'Book input is invalid', { errors }). The HTTP status and the status field can never disagree, because one argument sets both.

Request a missing book and check the headers:

HTTP/1.1 404 Not Found
content-type: application/problem+json

{"type":"about:blank","title":"Book not found","status":404}

What must never leak

The body goes to a stranger. So no stack traces, no SQL, no file paths, no secrets, no internal hostnames. Log all of that on the server, next to a request ID, and send the client a generic message plus that same ID. The logging lesson in the last module wires this up.

Hono gives you one place to catch anything you didn’t expect:

app.onError((err, c) => {
  console.error(err)
  return problem(c, 500, 'Something went wrong')
})

With this in place, a handler that throws produces one log line and one clean 500. Without it, a client sees Hono’s default text error and you have no consistent shape.

Clients must tolerate growth

Extension fields are how the format grows. Field-level validation errors, a request ID, a link to docs: all extensions. The rule for clients is to ignore fields they don’t know. The rule for you is to never remove or rename one once published.

Before moving on, replace every ad hoc error in the app with the helper. Then test it: status, media type, the four base fields, and a check that the body contains no SQL text, no path, and nothing from the environment.

Lesson completed