Two kinds of error
Everything else in this lesson follows from telling them apart. Handling an operational error is normal; catching a programmer error and carrying on hides a bug.
| Operational | Programmer | |
|---|---|---|
| Examples | Database down, 404, invalid input, timeout | undefined is not a function, a typo, a bad assumption |
| Expected? | Yes — they will happen | No — it is a bug |
| Answer | A status and a clear message | 500, and a log entry you go and fix |
| Recover? | Retry, fall back, tell the user | You cannot; the state is unknown |
An error class that carries a status
class HttpError extends Error {
constructor(status, message, options) {
super(message, options); // { cause } is preserved
this.name = "HttpError";
this.status = status;
this.expose = status < 500; // may this message be shown to a client?
}
}
const notFound = () => new HttpError(404, "Not found");
const badRequest = (msg) => new HttpError(400, msg);
throw new HttpError(409, "That door already exists");
cause keeps the original
throw new HttpError(502, "Payment provider unreachable", { cause: err }) — the stack of the real failure is still there when you log it.
One error handler
- It goes last, after every route and after the 404 handler.
- A 500 message never reaches the client: stack traces and database errors tell an attacker how you are built.
- Send a request id with the response, and log it — a user can quote it and you can find the exact request.
app.use((err, req, res, next) => {
const status = err.status ?? 500;
// the full truth goes to the log
logger.error({ err, url: req.url, method: req.method, requestId: req.id });
// and only what is safe goes to the client
res.status(status).json({
error: err.expose ? err.message : "Server error",
requestId: req.id,
});
});
Getting errors into it
next(err) is the only way into the error handler. Calling it from a catch is what connects your asynchronous code to the one place that formats errors.
// Express 4: a rejected promise reaches nobody, and the request hangs
app.get("/doors", async (req, res) => { throw new Error("boom"); });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get("/doors", wrap(async (req, res) => { … }));
// Express 5 forwards rejections on its own
Never swallow
try { await save(); } catch (e) {} // silence
try { await save(); } catch (e) { console.log("err"); } // useless log
catch (e) { throw new Error("Save failed"); } // the original is gone
// handle what you can, and pass on what you cannot
catch (err) {
if (err.code === "ENOENT") return null;
throw new HttpError(500, "Could not save the door", { cause: err });
}
The two process-level handlers
process.on("uncaughtException", (err) => {
logger.fatal({ err }, "uncaught exception");
process.exit(1); // the state is unknown — let the manager restart us
});
process.on("unhandledRejection", (reason) => {
logger.fatal({ reason }, "unhandled rejection");
process.exit(1);
});
These are not a safety net
Carrying on after an uncaught exception means running with corrupted state. Log it, exit, and let a process manager start a clean one — a restart is a second of downtime; a corrupted process is a day of confusion.
Logs you can search
- Use
pinoorwinston; log JSON in production and pretty text in development. - Levels that mean something:
errorneeds a human,warnis suspicious,infois a business event,debugis off in production. - Never log secrets, tokens, passwords or whole request bodies. Redact by key.
- A request id on every line, passed through to downstream calls.
// unsearchable
console.log("User " + id + " ordered door " + doorId);
// one JSON object per line: filter by any field, in any tool
logger.info({ userId: id, doorId, event: "order_created" }, "order created");