---
title: 'Error Response Format'
source: 'https://academia.sh/en/courses/api-design/error-response-format'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:27+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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,
    },
  };
}
```

```js
// 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.

```js
// 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}`);
```

```bash
#!/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.

```bash
#!/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.
