Lesson 11 / 16
Error Handling Layer
Client error is separated from programmer error, and a single layer translates both into the same response shape; the text sent to the client comes from a fixed table, internal detail stays only in the log, and the response bodies are tested for leaks across seven scenarios.
Contents
This lesson’s server had error handling squeezed into a single branch: a book not on the shelf
produced 409. In a real application, errors come from two separate sources. A member sends a
request with a missing field; this is a condition the application anticipates, and its response
should be informative. The application crashes on a defect in its own code; this is a condition
it did not anticipate, and its response should give away nothing.
This lesson separates the two sources and passes both through a single layer. Every error ends up translated into the same shape; the difference between them is how much of the body it carries.
Two Error Classes
A client error is a condition the application expects: a missing field, a malformed body, a
resource that does not exist, a conflicting state. These are not defects; they are the business
rule itself. Their response falls in the 4xx range and has to tell the client what to do.
A programmer error is the application’s own defect: calling a function that does not exist,
reading an undefined value, an assumption that did not hold. Their response falls in the 5xx
range and has nothing meaningful to say to the client — because there is nothing the client can
do about it.
This connects directly to the operational error and programmer error distinction set up in the Node.js Runtime course; the difference here is that it gets translated into an HTTP response.
The module below sets up the distinction with a class and a translation function.
// src/setup/errors.mjs — separates client error from programmer error and translates it into a response export class ClientError extends Error { constructor(code, status, details = null) { super(`client error: ${code}`); this.name = "ClientError"; this.code = code; // fixed identifier; the client branches on this this.status = status; this.details = details; // fields that are safe to show to the client } } // The single source of the text sent to the client. The error object's own message never enters the response. const MESSAGES = { body_malformed: "The request body could not be parsed as JSON.", field_missing: "The request is missing required fields.", member_missing: "A valid member id is required.", book_not_found: "This ISBN is not in the catalog.", shelf_empty: "The shelf has no copy of this book left.", internal_error: "The request could not be processed.", }; export const toResponse = (error, requestId) => { const known = error instanceof ClientError && error.code in MESSAGES; const code = known ? error.code : "internal_error"; const body = { error: { code, message: MESSAGES[code] }, requestId }; if (known && error.details) body.error.details = error.details; return { status: known ? error.status : 500, body }; };
Three decisions are hiding in these thirty lines.
The text sent to the client does not come from the error. The toResponse function never
reads error.message; it takes the message from a fixed table. Error messages are written for
the developer and can carry a file path, a query string, an internal variable name. A message
coming from the table makes that structurally impossible.
Everything unknown is an internal error. The condition passes not just ClientError
instances but ones whose code is found in the table. An error built with a code that was never
added to the table also becomes internal_error; the default direction is toward not leaking.
Details are marked explicitly. The details field carries only values the throwing code
deliberately placed there — the names of missing fields, the ISBN that was queried. This ties
the boundary between information that helps the client and internal detail to a single field
name.
Passing Through a Single Layer
Every handler in the server throws; none of them writes a response. The work of writing a
response sits in a single catch block. This is the application of the rule set up in an
earlier section: an error handler stands outside everything it protects.
// src/http/server.mjs — a single error layer: every error is translated into the same shape import { createServer } from "node:http"; import { ClientError, toResponse } from "../setup/errors.mjs"; const CATALOG = new Map([["978-0262033848", { onShelf: 0 }], ["978-0201835953", { onShelf: 3 }]]); let counter = 0; const parseBody = (text) => { try { return text ? JSON.parse(text) : {}; } catch { throw new ClientError("body_malformed", 400); } }; const lendBook = (input, member) => { if (!member) throw new ClientError("member_missing", 401); const missing = ["isbn"].filter((field) => !input[field]); if (missing.length > 0) throw new ClientError("field_missing", 400, { missingFields: missing }); const book = CATALOG.get(input.isbn); if (!book) throw new ClientError("book_not_found", 404, { isbn: input.isbn }); if (book.onShelf < 1) throw new ClientError("shelf_empty", 409, { isbn: input.isbn }); book.onShelf -= 1; return { loan: { isbn: input.isbn, member }, remaining: book.onShelf }; }; createServer(async (req, res) => { res.sendDate = false; const id = `r-${++counter}`; const write = (status, body) => { const text = JSON.stringify(body); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(text), "x-request-id": id }); res.end(text); }; try { const chunks = []; for await (const chunk of req) chunks.push(chunk); const path = new URL(req.url, "http://local").pathname; const member = (req.headers.authorization ?? "").replace(/^Member /, "") || null; if (path === "/loans") return write(201, lendBook(parseBody(Buffer.concat(chunks).toString()), member)); if (path === "/report") return write(200, { total: CATALOG.getAll().length }); // programmer error throw new ClientError("book_not_found", 404); } catch (error) { const expected = error instanceof ClientError; console.log(JSON.stringify({ // internal detail stays only in the log level: expected ? "warning" : "error", requestId: id, type: expected ? "client_error" : "programmer_error", name: error.name, message: error.message, stack: expected ? null : ((error.stack ?? "").split("\n")[1] ?? "") .trim().replace(/\(file:.*\/(?=[^\/]+:\d+:\d+\))/, "("), })); const { status, body } = toResponse(error, id); write(status, body); } }).listen(8439, "127.0.0.1", () => console.log(JSON.stringify({ event: "started", port: 8439 })));
The CATALOG.getAll() call on the /report path calls a method that does not exist and
produces a genuine programmer error; not a simulated one, but the natural consequence of a typo.
The log record puts the two error classes at separate levels. Client errors sit at the warning
level and carry no stack trace — because where the code is is already known. Programmer errors
sit at the error level and carry the first line of the stack trace; that is the only piece of
information needed to find the defect.
Testing Seven Scenarios
The correctness of the response shape is verified not by reading but by running every branch. The test below tries seven scenarios in order, compares each response’s status code against what is expected, and searches the body for a forbidden pattern.
The second half of the test matters: clean responses could also be produced by an application that silently swallows its errors. To rule that out, the test also verifies that the internal detail is actually found in the log. A test that passes only means something once both directions are checked together.
// audit/error-response.mjs — tests the shape of every error response and that it carries no leak. // Also verifies that the programmer error's internal detail is REALLY found in the log: // both directions are needed so the test does not approve an application that silently // swallows everything. import { spawn } from "node:child_process"; import { setTimeout as wait } from "node:timers/promises"; const FORBIDDEN = [/at\s+\S+\s+\(/, /\.mjs/, /node:/, /TypeError/, /\/Users\//, /client error:/]; const SCENARIOS = [ ["valid request", "/loans", "Member U-4711", '{"isbn":"978-0201835953"}', 201], ["no member id", "/loans", null, '{"isbn":"978-0201835953"}', 401], ["malformed body", "/loans", "Member U-4711", "{isbn", 400], ["missing field", "/loans", "Member U-4711", "{}", 400], ["not in catalog", "/loans", "Member U-4711", '{"isbn":"978-0000000000"}', 404], ["shelf empty", "/loans", "Member U-4711", '{"isbn":"978-0262033848"}', 409], ["programmer error", "/report", "Member U-4711", null, 500], ]; const child = spawn("node", ["src/http/server.mjs"]); let log = ""; child.stdout.on("data", (p) => (log += p)); child.stderr.on("data", (p) => (log += p)); await wait(700); const findings = []; console.log(`${"status".padEnd(6)} | ${"scenario".padEnd(18)} | response body`); for (const [name, path, id, body, expected] of SCENARIOS) { const res = await fetch(`http://127.0.0.1:8439${path}`, { method: body === null ? "GET" : "POST", headers: { ...(id ? { authorization: id } : {}), ...(body === null ? {} : { "content-type": "application/json" }) }, body, }); const text = await res.text(); console.log(`${String(res.status).padEnd(6)} | ${name.padEnd(18)} | ${text}`); if (res.status !== expected) findings.push(`${name}: expected ${expected}, got ${res.status}`); for (const pattern of FORBIDDEN) { if (pattern.test(text)) findings.push(`${name}: response body has forbidden pattern ${pattern}`); } } await wait(200); child.kill(); await wait(300); console.log("--- log ---"); console.log(log.trimEnd().split("\n").map((s) => " " + s).join("\n")); if (!/programmer_error/.test(log)) findings.push("no programmer error record in the log"); if (!/TypeError/.test(log)) findings.push("no internal error type in the log"); console.log("--- audit ---"); for (const finding of findings) console.log(" FINDING " + finding); console.log(findings.length === 0 ? " all seven responses match the shape; internal detail stays only in the log" : ` ${findings.length} findings`); process.exit(findings.length === 0 ? 0 : 1);
status | scenario | response body
201 | valid request | {"loan":{"isbn":"978-0201835953","member":"U-4711"},"remaining":2}
401 | no member id | {"error":{"code":"member_missing","message":"A valid member id is required."},"requestId":"r-2"}
400 | malformed body | {"error":{"code":"body_malformed","message":"The request body could not be parsed as JSON."},"requestId":"r-3"}
400 | missing field | {"error":{"code":"field_missing","message":"The request is missing required fields.","details":{"missingFields":["isbn"]}},"requestId":"r-4"}
404 | not in catalog | {"error":{"code":"book_not_found","message":"This ISBN is not in the catalog.","details":{"isbn":"978-0000000000"}},"requestId":"r-5"}
409 | shelf empty | {"error":{"code":"shelf_empty","message":"The shelf has no copy of this book left.","details":{"isbn":"978-0262033848"}},"requestId":"r-6"}
500 | programmer error | {"error":{"code":"internal_error","message":"The request could not be processed."},"requestId":"r-7"}
--- log ---
{"event":"started","port":8439}
{"level":"warning","requestId":"r-2","type":"client_error","name":"ClientError","message":"client error: member_missing","stack":null}
{"level":"warning","requestId":"r-3","type":"client_error","name":"ClientError","message":"client error: body_malformed","stack":null}
{"level":"warning","requestId":"r-4","type":"client_error","name":"ClientError","message":"client error: field_missing","stack":null}
{"level":"warning","requestId":"r-5","type":"client_error","name":"ClientError","message":"client error: book_not_found","stack":null}
{"level":"warning","requestId":"r-6","type":"client_error","name":"ClientError","message":"client error: shelf_empty","stack":null}
{"level":"error","requestId":"r-7","type":"programmer_error","name":"TypeError","message":"CATALOG.getAll is not a function","stack":"at Server.<anonymous> (server.mjs:44:64)"}
--- audit ---
all seven responses match the shape; internal detail stays only in the log
The test produces a zero exit code. The line and column number in the stack trace shift with every change to the code.
What the Measurement Shows
Six of the error responses share the same shape. All of them have error.code,
error.message, and requestId; two also carry details. On the client side, a single handler
can process every error and base its branching on the code field. The message itself is for
display; the decision is made on code, because the message can change while the code does not.
Details are present only when they help. The field_missing response names the missing
fields, the book_not_found response returns the queried ISBN. These are values the client
already knows or has to fix. The member error’s response carries no detail at all; saying which
id was invalid and why would mean giving feedback to a party that is guessing at ids.
The fifth scenario makes a distinction visible. An ISBN not in the catalog gets 404, a
book with no copy left on the shelf gets 409. Both say “I cannot give you this,” but one means
the resource never existed, the other that it exists but is not available right now. The client
fixes its input in the first case and waits in the second.
The seventh scenario is the whole point of the distinction. The body sent to the client
carries only the internal_error code, the message from the fixed table, and the r-7 id. The
log, on the other hand, holds the fact that the same request was a TypeError, that its message
was CATALOG.getAll is not a function, and the first line of the stack trace. The same event is
recorded in two places at two different levels of detail, and the two are matched by the r-7
id.
The test runs in both directions. The absence of a forbidden pattern in the responses is not
enough by itself; the test also checks that programmer_error and TypeError records exist in
the log. An application that silently swallows its errors would pass the first condition and
fail the second.
Summary
- Errors split into two classes: a client error is a condition the application anticipates and
produces
4xx, a programmer error is the application’s own defect and produces5xx. - The message sent to the client comes from a fixed table, not from the error object; any error
with no match in the table counts as
internal_error, meaning the default direction is toward not leaking. - Fields that are safe to show the client are marked explicitly under the
detailskey; the member error gives no detail at all. - Every handler throws, and a single layer writes the response; the log record keeps client
errors at the
warninglevel and programmer errors at theerrorlevel, adding a stack trace only to the latter. - The test does not stop at searching the seven scenarios’ response bodies for a forbidden pattern; it also verifies that the internal detail is found in the log. With both directions checked together, an application that swallows its errors cannot pass the test.
Next Step
The pieces set up in this section — a layered directory layout, configuration read from the environment and validated at startup, secrets kept separate, a structured log written to standard output, and a shared error response — look like independent decisions. There is a connection between them, and it converges on a single goal: making the application independent of the machine it runs on. The next lesson gathers these decisions into a named set of principles, shows which measurement in this section satisfies each one, and points out the ones that are not satisfied.
To keep your progress and take notes, Log in
My notes
Log in to take notes.