Error Handling
Chapter Forty-Eight
Syllabus topic Module 2, "Application Development: Error handling".
Pages 331 to 336 of 499
In one line
Error handling is deciding, for everything that can go wrong, whether it was expected or not: an expected refusal carries a status, a code for the program and a sentence for the person, and reaches the page beside the field it concerns; an unexpected failure is logged in full and answered with one plain apology that tells an attacker nothing.
In the wording to use when asked: error handling distinguishes anticipated domain errors, which are represented by a dedicated error type carrying an HTTP status, a machine-readable code and a human-readable message, from unanticipated failures, which are logged with their stack and answered with a generic 500; both are produced by a single centralised handler, so that every response has one shape and no internal detail is disclosed.
Two kinds of thing going wrong
| Expected | Unexpected | |
|---|---|---|
| Examples | a wrong password, a sold-out item, an order that is not yours, a quantity of 7 | the database is down, a null where an object was assumed, a typo in a query |
| Whose fault | usually the caller's, sometimes just the state of the canteen | ours |
| What the user sees | exactly what is wrong, and what to do | "Something went wrong on our side. Please try again." |
| What the log sees | one ordinary request line | the whole error, with its stack |
| What it is called here | an AppError | anything else |
The difference is decided when the error is created, not when it is caught. A service that knows the biryani has sold out throws an AppError; nothing else in the application throws one. So the handler at the end needs no cleverness: instanceof AppError is the whole question.
The error type, and the makers
'use strict';
// An error we expected and can explain to the user: a wrong
// password, a sold-out item, an order that is not theirs.
// Anything else that is thrown is a bug, and the error
// handler treats it as one (src/middleware/errors.js).
class AppError extends Error {
constructor(status, code, message, details) {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
this.details = details;
}
}
function badRequest(message, details) {
return new AppError(400, 'invalid_input', message, details);
}
function notSignedIn() {
return new AppError(401, 'not_signed_in',
'Please sign in first.');
}
function forbidden() {
return new AppError(403, 'forbidden',
'Your account is not allowed to do that.');
}
function notFound(what) {
return new AppError(404, 'not_found', `${what} not found.`);
}
function conflict(code, message, details) {
return new AppError(409, code, message, details);
}
function tooManyAttempts(retryAfterSeconds) {
return new AppError(429, 'too_many_attempts',
'Too many failed sign-ins. Try again later.',
{ retryAfterSeconds });
}
module.exports = {
AppError, badRequest, notSignedIn, forbidden, notFound,
conflict, tooManyAttempts,
};Each maker builds one kind of refusal, and the names say what they mean rather than what number they are: a route calls notFound('Order'), not new AppError(404, ...). Three things travel with every one:
Error Handling
- The status, which the browser and any program can act on;
- The
code, a fixed word the page can test,sold_out,slot_taken,invalid_move, which never changes when the wording does; - The
message, a plain sentence ready to show; and, where it helps,details: the fault in each field, or how many portions are left.
conflict takes its code because 409 covers several refusals, each of which a page may want to treat differently (Chapter 30).
The one handler
'use strict';
const path = require('node:path');
const { AppError } = require('../errors');
// An address under /api that no route answered.
function apiNotFound(req, res, next) {
next(new AppError(404, 'not_found',
'There is nothing at this address.'));
}
// Any other address that no file answered.
function pageNotFound(req, res) {
res.status(404).sendFile(
path.join(__dirname, '..', '..', 'public', '404.html'));
}
function send(res, status, code, message, details) {
res.status(status).json({ error: { code, message, details } });
}
// The last stop for every error. An AppError was expected,
// so its message is shown. Anything else is a bug: the user
// gets a plain apology and the details go to the log, never
// to the browser, where they would help an attacker.
function errorHandler(log) {
return (err, req, res, next) => {
if (res.headersSent) return next(err);
if (err instanceof AppError) {
if (err.status === 429) {
res.set('Retry-After', String(err.details.retryAfterSeconds));
}
return send(res, err.status, err.code, err.message,
err.details);
}
if (err.type === 'entity.parse.failed') {
return send(res, 400, 'bad_json',
'The request body is not valid JSON.');
}
if (err.type === 'entity.too.large') {
return send(res, 413, 'too_large',
'The request body is too large.');
}
log.error(`${req.method} ${req.originalUrl} failed:`, err);
send(res, 500, 'server_error',
'Something went wrong on our side. Please try again.');
};
}
module.exports = { apiNotFound, pageNotFound, errorHandler };Reading it in order is reading the policy:
- If the answer has already begun, hand the error back to Express: nothing else is honest.
- An
AppErroris answered with its own status, code, message and details. A 429 also gets itsRetry-Afterheader, the number of seconds from the limiter (Chapter 46). - Express's own parse failures are translated into the application's own shapes, 400
bad_jsonand 413too_large, so that the page meets one kind of error and never Express's wording. - Anything else is a bug: the method, the address and the whole error go to the log, and the browser gets one sentence with no code, no message from the error, no file name and no stack (S11).
Two more middlewares complete it: an unknown address under /api becomes a JSON 404, and any other unknown address gets the HTML page (Chapter 40), because a person who mistypes an address should see a page, not JSON.
Error Handling
The status codes, as this application uses them
Every one of these is produced in this chapter's transcripts.
| Code | Meaning | Here |
|---|---|---|
| 200 | it worked, here is the result | every successful read and change |
| 201 | it worked, and something new exists | a new order, account or menu item |
| 204 | it worked, nothing to send back | signing out |
| 400 | the request itself is wrong | validation (invalid_input), broken JSON (bad_json) |
| 401 | who are you? | not signed in, or a wrong email or password |
| 403 | we know, and you may not | the wrong role, or a change from another site |
| 404 | no such thing | an unknown address, an id that cannot name a row, someone else's order |
| 409 | not now, because of how things are | slot_closed, slot_taken, sold_out, item_unavailable, invalid_move, email_taken, name_taken |
| 413, 415 | too large; not JSON | bodies the server refuses to read |
| 429 | too many tries | five failed sign-ins in fifteen minutes |
| 500 | our fault | a bug: logged in full, apologised for plainly |
| 503 | not available at the moment | the health check when the database cannot be reached |
The line that matters most is 400 against 409. A quantity of 7 is a 400: it is wrong whatever the canteen is doing. An order for a sold-out item is a 409: the request is proper, and tomorrow the same one succeeds. Getting that line right is what lets a page say something useful.
One order route, six answers
The order route can produce most of them. Each of these is one request:
$ cd ~/canteen-preorder
$ npm start > server.log 2>&1 &
$ curl -s -o /dev/null --retry 10 --retry-connrefused localhost:3000/api/health
$ order() { curl -s -o /tmp/body -w "%{http_code}" -b "$1" \
> -H 'Content-Type: application/json' -d "$2" \
> localhost:3000/api/orders; echo " $(jq -c '.error.code // .order.id' /tmp/body)"; }
$ order none '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
401 "not_signed_in"
$ curl -s -c lata -o /dev/null -H 'Content-Type: application/json' \
> -d '{"email":"owner@college.example","password":"canteen-demo"}' \
> localhost:3000/api/auth/login
$ order lata '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
403 "forbidden"
$ curl -s -c priya -o /dev/null -H 'Content-Type: application/json' \
> -d '{"email":"priya@college.example","password":"canteen-demo"}' \
> localhost:3000/api/auth/login
$ order priya '{"slot":"12:40","items":[{"menuItemId":1,"quantity":7}]}'
400 "invalid_input"
$ order priya '{"slot":"12:40","items":[{"menuItemId":5,"quantity":1}]}'
409 "item_unavailable"
$ order priya '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
201 1
$ order priya '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
409 "slot_taken"Six answers, in the order a student might meet them: 401 with nobody signed in; 403 for the owner, who is not a student; 400 invalid_input for a quantity of 7; 409 item_unavailable for an item that is off today's menu; 201 with the order's number; and 409 slot_taken for a second order in the same slot, which is FR-10.
Error Handling
The last two are the pair to remember: the same request, sent twice, is right the first time and refused the second, because the state of the canteen changed in between. That is what a 409 is for.
What a person is told
A code is for the program; the page shows the message (Chapter 41). Three levels:
- A field's message, beside the field, for a refusal of input: "Choose 1 to 5."
- The page's message area, for anything else: "Only 2 Chicken Biryani left."
- The apology, for a 500: "Something went wrong on our side. Please try again."
And one more, which is easy to forget: no connection at all. api.js turns a failed fetch into an error of the same shape, with status 0 and a message about the network (Chapter 40), because on a phone at 12:15 the commonest failure is the Wi-Fi.
When something really goes wrong
The health check is the one route that answers a failure with a status of its own, because that is its whole job (Chapter 42). Stopping MySQL shows it and an ordinary route meeting the same problem, and shows what a 500 does and does not say:
$ cd ~/canteen-preorder
$ sudo mysqladmin shutdown
$ curl -s -o /dev/null -w "health: %{http_code}\n" localhost:3000/api/health
health: 503
$ curl -s localhost:3000/api/health | jq -c .
{"status":"down","database":"unreachable"}
$ curl -s -o /dev/null -w "menu: %{http_code}\n" localhost:3000/api/menu
menu: 500
$ curl -s localhost:3000/api/menu | jq -c .
{"error":{"code":"server_error","message":"Something went wrong on our side. Please try again."}}
$ grep -c 'GET /api/menu failed' server.log
2
$ grep -o 'Error: [^,]*' server.log | head -1
Error: connect ECONNREFUSED 127.0.0.1:3306
$ lab-entry true
$ curl -s -o /dev/null -w "health again: %{http_code}\n" localhost:3000/api/health
health again: 200
$ curl -s localhost:3000/api/menu | jq -c '.items | length'
14Three things to see. The health check answers 503, naming the database, which is what a monitor reads. The menu answers 500 with the apology and nothing else: no code from the driver, no address, no stack, because a page asking for the menu can do nothing with any of it (S11). And the log has the failure in full, with the method, the address and the driver's own error, which is what a developer needs.
Then MySQL is started again, and the next request succeeds: the pool opens new connections by itself, and nothing had to be restarted. A system that needs a restart after its database hiccups is a system that will be restarted during the demonstration.
Error Handling
Do this for your project
- Decide, for every failure, whether it is expected. Throw one kind of error for the expected ones, from the place that knows.
- Give each a status, a code for programs and a sentence for people, and details only where they help.
- Handle them all in one place, at the end of the pipeline.
- Never send a stack, a file name or a database message to a browser; log them instead.
- Keep 400 for what is always wrong and 409 for what is wrong now.
- Answer an unknown API address with JSON and an unknown page address with a page.
- Handle "no connection at all" in the client as carefully as any refusal.
Mistakes that cost marks
Everything is a 500, or worse, everything is a 200 with {"ok": false} inside.
The stack in the browser, which is a map of the server for anyone who asks.
A different error shape from every route, so the pages need a special case each.
catch (err) {}, which turns a bug into wrong behaviour nobody can find.
Codes the pages test by their English, so changing "Sold out" to "Sold out for today" breaks the application.
No message for a lost connection, so a student on bad Wi-Fi sees a page that does nothing at all.
Quick revision
- Expected (
AppError: status, code, message, details) against unexpected (logged, 500, one sentence). - Makers:
badRequest,notSignedIn,forbidden,notFound,conflict(code, ...),tooManyAttempts. - One handler at the end:
headersSent,AppError, Express's parse errors translated, everything else logged. - 400 = always wrong; 409 = wrong now. 401 = who are you; 403 = you may not; 404 = no such thing.
- Retry-After with a 429; 503 from the health check; status 0 in the client for no connection.
- Unknown
/apiaddress: JSON 404. Unknown page: the 404 page.
Questions you must be able to answer
1. How does the application tell an expected refusal from a bug? By its type. Anything the code knew could happen is thrown as an AppError carrying a status, a code and a message; nothing else creates one. The handler at the end answers an AppError with its own details and treats everything else as a bug.
2. Why are 400 and 409 both used, and how do you choose? 400 is for a request that is wrong whatever the state of the system, such as a quantity above the limit. 409 is for a request that is proper but cannot be carried out now, such as ordering an item that has sold out or a second order for the same slot. The same 409 request may succeed later.
Error Handling
3. Why does a 500 tell the user so little? Because the details help an attacker more than the user: a stack trace reveals the framework, the file layout and often the database's table names, and a user can do nothing with any of it. The details go to the log, where the developers can read them.
4. What is the code in an error for, when there is already a message? The message is for the person and may be reworded at any time; the code is a fixed word for the program, so a page can treat sold_out differently from slot_taken without ever testing English text.
5. What happens when the database is down? The health check answers 503, saying the database is unreachable, which is what a monitor watches. Any other route's query fails, the error is logged in full, and the browser is told only that something went wrong. When the database comes back, the pool opens new connections and requests succeed again.
6. Why does an unknown address under /api answer JSON while any other unknown address answers a page? Because the caller of an API address is a program, which needs the same shape of answer as every other refusal, while the caller of any other address is a person in a browser, who should see a readable page with a way back.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.