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:
| Class | Meaning | You will use |
|---|---|---|
1xx | informational, rare in APIs | |
2xx | it worked | 200 OK, 201 Created, 204 No Content |
3xx | look elsewhere | 304 Not Modified (lesson 5), 307, 308 |
4xx | the client's mistake: fix the request before retrying | 400, 401, 403, 404, 405, 409, 415, 422, 429 |
5xx | the server's fault: the same request may work later | 500 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 anAllowheader 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 GET | keeps the method | |
|---|---|---|
| permanent | 301 Moved Permanently | 308 Permanent Redirect |
| temporary | 302 Found | 307 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
POST /catsanswers a body that is not valid JSON with400and{ "error": "The body is not valid JSON" }.- 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. - It answers a name that is already taken with
409and{ "error": "A cat named <name> already exists" }. - Every other method on
/catsanswers405withAllow: GET, POSTand{ "error": "<METHOD> is not allowed on /cats" }. /kittensand/kittens/<id>redirect permanently, keeping the method, to/catsand/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 forGETonly.app.all. - The redirect is
302:c.redirect()defaults to it. Pass308.
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
unknownand check every field; the type argument ofjson<T>()checks nothing. - Redirect APIs with
308or307, 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.