Lesson 18 / 34
Error Response Format
The cost to the client of improvising the error body per endpoint, the five core fields of the standard problem details format, and verifying with a check that a single-layer problem catalog produces the body.
Contents
The Connection-Based Responses lesson put navigation information inside the successful response: when the client received a loan record, it could read from the response itself how to reach the return action and the member. Resource modeling, address layout, status code selection, body naming, and links — everything designed so far described the case where the request succeeded.
Failed requests fell outside that design. In the loan service’s real traffic there are requests where the book cannot be found, no copy remains, a limit is exceeded, or the body is malformed. This lesson answers a single question: what should the error response’s body look like so the client does not have to relearn it for every endpoint?
Two Error Formats, One Service
The server below serves the loan service’s familiar core — looking up a book, lending it,
returning it — and can produce the error body in two separate formats. The routing code is
identical in both; only the body that the sendError() call produces changes. The
SCATTERED table is the shape a codebase settles into over time when the error body is
never written into the contract: each condition is reported with whatever names occurred to
the person who wrote that line. The PROBLEM table instead maps every condition to a
problem type catalog.
// problem.mjs — the problem details layer: the single place that produces the body export const BASE_TYPE = "https://example.library/problems/"; // Catalog: each problem type is defined once. Type -> permanent id, title, status code. export const CATALOG = { resource_not_found: { title: "Resource not found", status: 404 }, malformed_body: { title: "Request body could not be parsed", status: 400 }, no_copy_available: { title: "No copy available to loan", status: 409 }, member_limit: { title: "Member reached the loan limit", status: 409 }, validation: { title: "Request body failed validation", status: 422 }, }; let counter = 0; const occurrenceId = () => `oc-${String(++counter).padStart(4, "0")}`; // problem(code, detail, extra) -> { body, status } export function problem(code, detail, extra = {}) { const record = CATALOG[code]; if (!record) throw new Error(`unknown problem type in catalog: ${code}`); return { status: record.status, body: { type: BASE_TYPE + code.replaceAll("_", "-"), title: record.title, status: record.status, detail, instance: occurrenceId(), ...extra, }, }; }
// server.mjs — loan service; same routes, two separate error formats // Usage: node server.mjs scattered (body improvised per endpoint) // node server.mjs problem (problem details format) import { createServer } from "node:http"; import { problem } from "./problem.mjs"; const STYLE = process.argv[2] ?? "scattered"; // Scattered format: whatever body was written for each error condition on the day. const SCATTERED = { book_not_found: (b) => [404, { error: "not_found", resource: "book", id: b.id }], loan_not_found: (b) => [404, { message: `loan not found: ${b.id}` }], member_not_found: () => [404, { error: { code: 4041, description: "member not found" } }], malformed_body: () => [400, { ok: false, reason: "invalid JSON" }], no_copy_available: () => [409, { conflict: "no_copy_available", remaining: 0 }], member_limit: (b) => [409, { errorMessage: "member limit exceeded", limit: b.limit }], path_not_found: (b) => [404, { status: 404, path: b.path }], }; // Problem format: every condition maps to a problem type in the catalog. const PROBLEM = { book_not_found: (b) => problem("resource_not_found", `ISBN ${b.id} is not in the catalog.`, { resource: "book" }), loan_not_found: (b) => problem("resource_not_found", `Loan record ${b.id} does not exist.`, { resource: "loan" }), member_not_found: (b) => problem("resource_not_found", `Member ${b.id} does not exist.`, { resource: "member" }), malformed_body: () => problem("malformed_body", "Body is not valid JSON.", { resource: "body" }), no_copy_available: (b) => problem("no_copy_available", `Copies of ${b.title} are all on loan.`, { isbn: b.id, remaining: 0 }), member_limit: (b) => problem("member_limit", `Member holds ${b.open} books, limit ${b.limit}.`, { limit: b.limit, open: b.open }), path_not_found: (b) => problem("resource_not_found", `Path ${b.path} is not defined.`, { resource: "path" }), }; const sendError = (res, code, context = {}) => { if (STYLE === "problem") { const { status, body } = PROBLEM[code](context); res.writeHead(status, { "content-type": "application/problem+json; charset=utf-8" }); return res.end(JSON.stringify(body)); } const [status, body] = SCATTERED[code](context); res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(body)); }; const BOOKS = new Map([ ["978-0201896831", { title: "The Art of Computer Programming", copies: 2 }], ["978-0262033848", { title: "Introduction to Algorithms", copies: 1 }], ]); const MEMBERS = new Set(["U-1001", "U-1002"]); const MEMBER_LIMIT = 2; const loans = [{ id: "O-1", member: "U-1001", isbn: "978-0262033848" }]; const readBody = (req) => new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); }); const respond = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); }; createServer(async (req, res) => { res.sendDate = false; const path = req.url.split("?")[0]; if (req.method === "GET" && path.startsWith("/books/")) { const isbn = path.slice("/books/".length); const book = BOOKS.get(isbn); if (!book) return sendError(res, "book_not_found", { id: isbn }); return respond(res, 200, { isbn, ...book }); } if (req.method === "GET" && path.startsWith("/loans/")) { const id = path.slice("/loans/".length); const record = loans.find((o) => o.id === id); if (!record) return sendError(res, "loan_not_found", { id }); return respond(res, 200, record); } if (req.method === "POST" && path === "/loans") { let body; try { body = JSON.parse((await readBody(req)) || "{}"); } catch { return sendError(res, "malformed_body"); } if (!MEMBERS.has(body.member)) return sendError(res, "member_not_found", { id: body.member }); const book = BOOKS.get(body.isbn); if (!book) return sendError(res, "book_not_found", { id: body.isbn }); const held = loans.filter((o) => o.isbn === body.isbn).length; if (held >= book.copies) return sendError(res, "no_copy_available", { id: body.isbn, title: book.title }); const open = loans.filter((o) => o.member === body.member).length; if (open >= MEMBER_LIMIT) return sendError(res, "member_limit", { limit: MEMBER_LIMIT, open }); const id = `O-${loans.length + 1}`; loans.push({ id, member: body.member, isbn: body.isbn }); return respond(res, 201, { id, member: body.member, isbn: body.isbn }); } if (req.method === "POST" && path === "/returns") { const body = JSON.parse((await readBody(req)) || "{}"); const index = loans.findIndex((o) => o.id === body.loanId); if (index < 0) return sendError(res, "loan_not_found", { id: body.loanId }); loans.splice(index, 1); return respond(res, 200, { status: "returned" }); } sendError(res, "path_not_found", { path }); }).listen(8431, "127.0.0.1", () => console.log(`server 127.0.0.1:8431 style=${STYLE}`));
The Problem Details Format
The shape the PROBLEM table produces is not improvised: there is a standardized format
for the error body. Problem details is defined by RFC 9457, is carried with the
application/problem+json content type, and has five core fields.
type— the problem type’s permanent identifier, a URI. This is the field the client branches on. It is an identifier, not text; it stays the same even if the title changes.title— the problem type’s short, human-readable name. It is always the same for the sametype.status— the HTTP status code’s copy inside the body.detail— an explanation specific to this instance: which ISBN, which member, which limit.instance— this single occurrence’s identifier; it lets the log record be found.
The distinction between type and detail is the format’s core. type names the
class, detail describes the instance. The client decides by looking at the type
field (“show the reservation button if there is no copy”), and only shows the detail field
to the user or writes it to the log. If this distinction is not maintained, the client is
forced into text comparison, and a spelling fix on the server breaks the client’s logic.
Repeating the status field in the body looks unnecessary at first glance — it is already
in the status line. It becomes necessary in two situations: when the response is captured
as a body into a log or a queue, the status line is lost; and when an intermediary changes
the status code, the mismatch between the body’s value and the status line makes that
change visible.
The standard allows adding extension members outside the core. The number of remaining
copies, the limit value, which resource was looked up and not found — these are extension
members, and they depend on the problem type: the same type always comes with the same
extension members.
Measurement
The script below produces the server’s eight error conditions in turn, collects the bodies, and counts three things: how many distinct content types come back, how many distinct key sets result, and how many distinct combinations the five core fields appear in.
// check.mjs — measures the format consistency of a server's error responses // Usage: node check.mjs <base-address> const BASE = process.argv[2]; const CORE = ["type", "title", "status", "detail", "instance"]; // Each row produces one error condition: [name, method, path, body] const CASES = [ ["book not found (GET)", "GET", "/books/978-0000000000", null], ["loan not found (GET)", "GET", "/loans/O-99", null], ["malformed body", "POST", "/loans", "{this is not json"], ["member not found", "POST", "/loans", { member: "U-9999", isbn: "978-0201896831" }], ["book not found (POST)", "POST", "/loans", { member: "U-1001", isbn: "978-0000000000" }], ["no copy available", "POST", "/loans", { member: "U-1002", isbn: "978-0262033848" }], ["loan not found (return)", "POST", "/returns", { loanId: "O-99" }], ["path not found", "GET", "/shelves", null], ]; const keysOf = (n, prefix = "") => Object.entries(n).flatMap(([a, d]) => d && typeof d === "object" && !Array.isArray(d) ? keysOf(d, `${prefix}${a}.`) : [`${prefix}${a}`]); const coreShapes = new Set(); const fullShapes = new Set(); const types = new Set(); console.log("case content type core extension members"); for (const [name, method, path, body] of CASES) { const response = await fetch(BASE + path, { method, headers: body == null ? {} : { "content-type": "application/json" }, body: body == null ? undefined : typeof body === "string" ? body : JSON.stringify(body), }); const text = await response.text(); const type = (response.headers.get("content-type") ?? "").split(";")[0]; types.add(type); let list; try { list = keysOf(JSON.parse(text)); } catch { list = ["<not parsed>"]; } const core = CORE.filter((a) => list.includes(a)); const extra = list.filter((a) => !CORE.includes(a)).sort(); coreShapes.add(core.join(",")); fullShapes.add([...core, ...extra].join(",")); const status = core.length === CORE.length ? "full" : `partial(${core.length}/5)`; console.log(`${name.padEnd(24)} ${type.padEnd(24)} ${status.padEnd(14)} ${extra.join(",") || "-"}`); } console.log(`\nerror conditions tested: ${CASES.length}`); console.log(`distinct content types: ${types.size} (${[...types].join(", ")})`); console.log(`distinct full key sets: ${fullShapes.size}`); console.log(`distinct core shapes: ${coreShapes.size}`);
#!/usr/bin/env bash # Starts server.mjs in each format in turn and runs the same check. for style in scattered problem; do node server.mjs "$style" & s=$! sleep 0.5 node check.mjs http://127.0.0.1:8431 kill "$s"; wait "$s" 2>/dev/null echo done
server 127.0.0.1:8431 style=scattered case content type core extension members book not found (GET) application/json partial(0/5) error,id,resource loan not found (GET) application/json partial(0/5) message malformed body application/json partial(0/5) ok,reason member not found application/json partial(0/5) error.code,error.description book not found (POST) application/json partial(0/5) error,id,resource no copy available application/json partial(0/5) conflict,remaining loan not found (return) application/json partial(0/5) message path not found application/json partial(1/5) path error conditions tested: 8 distinct content types: 1 (application/json) distinct full key sets: 6 distinct core shapes: 2 server 127.0.0.1:8431 style=problem case content type core extension members book not found (GET) application/problem+json full resource loan not found (GET) application/problem+json full resource malformed body application/problem+json full resource member not found application/problem+json full resource book not found (POST) application/problem+json full resource no copy available application/problem+json full isbn,remaining loan not found (return) application/problem+json full resource path not found application/problem+json full resource error conditions tested: 8 distinct content types: 1 (application/problem+json) distinct full key sets: 2 distinct core shapes: 1
In the scattered format, eight error conditions produce six distinct key sets, and none of
them share a common core. error, message, reason, errorMessage, error.description
— all of them carry the same thing, the “what happened” information, and each one uses a
different name. The request layer built in the Application Architecture course has to map
the response to an error contract, and no mapping can be written against this table.
The client either writes six separate parsers or looks at a single field and ignores the
rest; the second option is common, and it turns into the sentence “an error occurred” on
the user’s screen.
In the problem format, the number of distinct core shapes drops to one, and all five fields
are present on every row. The full key set count is two, because the extension members
differ, and they should differ: the no_copy_available problem has a remaining field,
the resource_not_found problem does not. But this difference is tied to the problem
type, not the endpoint. When the client reads the type field it knows which extension
members will come; in the scattered format it had to know which endpoint it had called.
The content type row shows a second gain. In the scattered format, the error response and
the success response carry the same content type; the intermediaries and logging tools in
between cannot tell whether this is an error without parsing the body.
application/problem+json gives that distinction without looking at the body.
The Same Problem Returned From Different Endpoints
In the table, the resource_not_found problem appears on six rows. For the client to be
able to rely on this, those rows’ type, title, and status fields must be identical.
The script below produces the same problem from two different endpoints, then requests a
type that is not written into the catalog.
#!/usr/bin/env bash # Produces the same problem type from two different endpoints; tries a type outside the catalog. node server.mjs problem & server=$! sleep 0.5 for path in /books/978-0000000000 /loans/O-99; do echo "--- $path ---" curl -sS -D - -o /tmp/body "http://127.0.0.1:8431$path" | grep -i '^HTTP\|^content-type' cat /tmp/body; echo done kill "$server"; wait "$server" 2>/dev/null echo "--- requesting a problem type outside the catalog ---" node -e 'import("./problem.mjs").then(({ problem }) => { try { problem("shelf_is_dusty", "A made-up error."); } catch (h) { console.log(h.message); } });'
server 127.0.0.1:8431 style=problem
--- /books/978-0000000000 ---
HTTP/1.1 404 Not Found
content-type: application/problem+json; charset=utf-8
{"type":"https://example.library/problems/resource-not-found","title":"Resource not found","status":404,"detail":"ISBN 978-0000000000 is not in the catalog.","instance":"oc-0001","resource":"book"}
--- /loans/O-99 ---
HTTP/1.1 404 Not Found
content-type: application/problem+json; charset=utf-8
{"type":"https://example.library/problems/resource-not-found","title":"Resource not found","status":404,"detail":"Loan record O-99 does not exist.","instance":"oc-0002","resource":"loan"}
--- requesting a problem type outside the catalog ---
unknown problem type in catalog: shelf_is_dusty
The first three fields are identical; detail and instance differ. The client’s status
code mapping looks at the type field and writes a single branch for both endpoints; the
text to show the user comes from the detail field; instance is written into the support
record.
The last line shows the catalog’s second function. The catalog does not only hold the
mapping between type, title, and status; it errors when a type it does not have is
requested. A problem not written into the catalog cannot silently produce a new shape. What
prevents the format from scattering again over time is not that the format is designed
correctly, but that misuse is visible at runtime.
What Does Not Belong in the Body
Producing the error body from a single place makes what stays out controllable, just as
much as what goes in. Stack traces, database query text, file paths, and internal
identifiers do not go to the client; they are kept in the server log, matched by the
instance field. When the client reports a problem, it gives the occurrence identifier,
and the full context is found from the log.
The criterion rests on who can do what: information the client can change its behavior on goes into the body (no copies left, limit is two, ISBN not found); information the client can do nothing about goes into the log. This criterion gives the same result for both security and usability reasons.
Summary
- When the error body is written per endpoint, eight error conditions produce six distinct key sets and none of them share a common core; the client cannot build a single error contract on this table.
- The problem details format’s five core fields each serve a separate purpose:
typenames the class,titleis the class’s fixed name,statusrepeats the status code in the body,detaildescribes the instance,instancematches the single occurrence to the log. - The client branches on the
typefield; branching on thedetailtext turns a spelling fix on the server into a breaking change. - Producing the body from a single layer built on the problem type catalog brings the distinct core shape count down to one; extension members can still differ, because they depend on the problem type, not the endpoint.
- The catalog makes the format’s re-scattering visible at runtime by erroring when a type it does not have is requested.
- Information the client can change its behavior on is written into the body; information
it cannot is written into the log record matched by
instance.
Next Step
The validation type in the catalog was never used in this lesson. The reason is that a
validation error does not fit into a single sentence: a loan request with four wrong fields
at once cannot be sufficiently described by the title “request body failed validation.” The
client needs to show each wrong field next to its own input box, and that requires the
server to report, field by field, which field was rejected and why. The next lesson adds a
field-level error list to problem details, decides the rule by which field names match the
client’s fields, and measures the case where the match breaks down.
To keep your progress and take notes, Log in
My notes
Log in to take notes.