Skip to content
academia.sh

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 same type.
  • 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: type names the class, title is the class’s fixed name, status repeats the status code in the body, detail describes the instance, instance matches the single occurrence to the log.
  • The client branches on the type field; branching on the detail text 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.

Start typing to search.

↑↓ Esc navigate · open · close