InteractiveFrameworks

Status codes

A status code is the first thing a client reads and often the only thing it acts on. Replace a crash with a 400, tell a bad request from a conflict, refuse a method with the methods that work, and move a path without breaking its clients.

What you'll learn

  • Read a status code by its class: 2xx success, 3xx go elsewhere, 4xx the client's mistake, 5xx the server's
  • Choose between 400, 404, 405, 409 and 422 by what went wrong
  • Redirect permanently with 308 and a Location, keeping the method, and answer 405 with an Allow header

A client reads the status code first, and often acts on nothing else: retry or give up, show the form's errors or a crash page, follow a redirect or stop. A server that answers 200 with { "error": ... } in the body, or lets a typo in a request become a 500, makes every client read prose to learn what happened. This lesson is about codes that say it.

Five classes

The first digit is the class, which a client understands even for a code it has never seen:

ClassMeaningYou will use
1xxinformational, rare in APIs
2xxit worked200 OK, 201 Created, 204 No Content
3xxlook elsewhere304 Not Modified (lesson 5), 307, 308
4xxthe client's mistake: fix the request before retrying400, 401, 403, 404, 405, 409, 415, 422, 429
5xxthe server's fault: the same request may work later500 Internal Server Error, 503 Service Unavailable

The line between 4xx and 5xx matters most. A 5xx says the request was fine and something broke on your side, so clients retry and monitoring pages you at night. Broken JSON from a client is not your fault, and must never become an uncaught exception and a 500.

Choosing a 4xx

Ask what exactly is wrong, in this order:

  • 400 Bad Request: the request cannot even be read. Broken JSON, a missing required header, a query value that is not a number.
  • 404 Not Found: the URL names nothing.
  • 405 Method Not Allowed: the URL names something, and this method does not apply to it. The response must carry an Allow header listing the methods that do, so the client can correct itself.
  • 422 Unprocessable Content: the request was read fine and makes no sense. Valid JSON describing a cat with a negative age.
  • 409 Conflict: the request is fine on its own and clashes with the current state: a name that must be unique and is already taken.

401 (who are you?) and 403 (I know who you are, and no) come with authentication. Some APIs answer 400 to every client error, which is not wrong; the finer codes earn their place when a client would act differently on each.

Here is the pattern on owners, where an email must be unique:

app.post('/owners', async (c) => {
  let body: unknown;
  try {
    body = await c.req.json();
  } catch {
    return c.json({ error: 'The body is not valid JSON' }, 400);
  }
  const { email } = (body ?? {}) as { email?: unknown };
  if (typeof email !== 'string' || !email.includes('@')) return c.json({ error: 'email must be an email address' }, 422);
  if (owners.some((o) => o.email === email)) return c.json({ error: `${email} is already registered` }, 409);
  // ...store it, 201
});

The type argument of c.req.json<T>() checks nothing at runtime, so the parsed value starts as unknown and each field is checked before it is trusted. Every error has one shape, { "error": "..." }, so a client needs one piece of code to show any of them. (The standard shape is Problem Details, RFC 9457.)

405 and the order of routes

Hono tries routes in the order they were registered, and the first that answers wins. So a catch-all registered after the real routes answers only what they did not:

app.get('/owners', listOwners);
app.all('/owners', (c) => c.json({ error: '...' }, 405, { Allow: 'GET' }));

c.json(body, status, headers) takes headers as its third argument.

Moving a URL

When a path changes, the old one should point at the new one. Four redirect codes differ on two questions: is the move permanent, and may the client change the method?

may turn POST into GETkeeps the method
permanent301 Moved Permanently308 Permanent Redirect
temporary302 Found307 Temporary Redirect

Browsers do turn a POST redirected by 301 or 302 into a GET, silently dropping its body, so an API uses 308 and 307. The target goes in Location: c.redirect(url, 308).

Your task

  1. POST /cats answers a body that is not valid JSON with 400 and { "error": "The body is not valid JSON" }.
  2. It answers valid JSON that is not a valid cat with 422: { "error": "name must be a non-empty string" } unless the name is a string with something other than spaces, then { "error": "age must be a whole number, 0 or more" } unless the age is one.
  3. It answers a name that is already taken with 409 and { "error": "A cat named <name> already exists" }.
  4. Every other method on /cats answers 405 with Allow: GET, POST and { "error": "<METHOD> is not allowed on /cats" }.
  5. /kittens and /kittens/<id> redirect permanently, keeping the method, to /cats and /cats/<id>.

When it fails

  • "Broken JSON" answers 500: c.req.json() threw and nothing caught it. That is the difference between the client's mistake and yours.
  • "A blank name" is stored: the check was name === ''; trim first.
  • "DELETE the collection" answers 404: the catch-all is missing, or registered for GET only. app.all.
  • The redirect is 302: c.redirect() defaults to it. Pass 308.

Remember

  • The first digit is the class; 4xx is the client's to fix, 5xx is yours.
  • 400 unreadable, 404 no such thing, 405 wrong method with Allow, 422 readable but invalid, 409 clashes with state.
  • Parse into unknown and check every field; the type argument of json<T>() checks nothing.
  • Redirect APIs with 308 or 307, which keep the method.
Stuck? Show a hint

c.req.json() throws on a body it cannot parse: put it in try/catch and answer 400 from the catch. Check the fields of the parsed value with typeof and Number.isInteger before trusting them. app.all('/cats', …) registered after the GET and POST routes answers only the methods they did not; c.json(body, 405, { Allow: 'GET, POST' }) sets the header. c.redirect(url, 308) redirects.