---
title: 'Status Code Selection'
source: 'https://academia.sh/en/courses/api-design/status-code-selection'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:30+00:00'
license: 'CC BY-SA 4.0'
---

# Status Code Selection

Answering the same set of scenarios with two different code mappings and measuring the difference on the client: distinguishing close codes, the meanings of the 2xx family, the silent success a wrong mapping produces, and retries spent for nothing.

The previous lesson measured the promises a method carries, and ended on a gap: a server
that returns 404 to anything unmatched collapses a missing resource and an inapplicable
method into the same number. Throughout that lesson, the codes 201, 204, and 409 were used
without justification too.

This lesson builds that justification. The status code is the one part of a response the
client can read **without interpretation**. The body's `error` field is written to be read
by a human and differs across services; the status code, by contrast, is standard, and
components like the request layer, the cache, and the retry mechanism make their decisions
by looking at it. Choosing the wrong code means these components decide wrong — and that is
a measurable difference.

## The Same Scenarios, Two Mappings

The server below is a single file that runs in two modes. In `strict` mode, every outcome is
reported with its own code. In `loose` mode, a common shortcut is applied: 200 with an
`error` field in the body for anything not found, and 500 for every other failure.

```js
// code-server.mjs — answers the same scenarios in two different status-code mapping modes
// Usage: node code-server.mjs <port> <strict|loose>
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const [PORT, MODE] = [Number(process.argv[2]), process.argv[3]];
const MEMBER_LIMIT = 2;
const db = new DatabaseSync("library.db");

const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data));
});
const send = (response, status, data, extraHeaders = {}) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8", ...extraHeaders });
  response.end(data === null ? "" : JSON.stringify(data));
};
// Loose mode: reports every outcome as 200 or 500, leaving the distinction to the body.
const report = (response, status, data, extraHeaders = {}) => {
  if (MODE === "strict") return send(response, status, data, extraHeaders);
  if (status >= 400 && status < 500) return send(response, status === 404 ? 200 : 500, data ?? {});
  return send(response, 200, data ?? { result: "ok" });
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const book = /^\/books\/([^/]+)$/.exec(path);
  const loan = /^\/loans\/(\d+)$/.exec(path);

  if (request.method === "GET" && book) {
    const b = db.prepare("SELECT * FROM book WHERE isbn = ?").get(book[1]);
    return b ? report(response, 200, b) : report(response, 404, { error: "book_not_found" });
  }

  if (request.method === "POST" && path === "/loans") {
    const type = request.headers["content-type"] ?? "";
    if (!type.startsWith("application/json"))
      return report(response, 415, { error: "unsupported_type", expected: "application/json" });
    const raw = await readBody(request);
    let g;
    try { g = JSON.parse(raw); }
    catch { return report(response, 400, { error: "body_not_parsed" }); }

    if (!db.prepare("SELECT 1 FROM book WHERE isbn = ?").get(g.isbn))
      return report(response, 422, { error: "validation", field: "isbn", reason: "not_in_catalog" });

    const open = db.prepare("SELECT COUNT(*) AS n FROM loan WHERE member = ? AND return IS NULL").get(g.member).n;
    if (open >= MEMBER_LIMIT)
      return report(response, 409, { error: "member_limit", limit: MEMBER_LIMIT, open });

    const s = db.prepare("INSERT INTO loan (member, isbn, issuedAt, return) VALUES (?,?,?,NULL)")
                .run(g.member, g.isbn, g.issuedAt);
    const id = Number(s.lastInsertRowid);
    return report(response, 201, { id }, { location: `/loans/${id}` });
  }

  if (request.method === "DELETE" && loan) {
    db.prepare("DELETE FROM loan WHERE id = ?").run(Number(loan[1]));
    return report(response, 204, null);
  }

  // Resource exists but the method cannot be applied: not "not found."
  if (/^\/members\/[^/]+\/status$/.test(path))
    return report(response, 405, { error: "method_not_allowed" }, { allow: "GET" });

  report(response, 404, { error: "path_not_found" });
});

server.listen(PORT, "127.0.0.1", () => console.log(`${MODE} mode 127.0.0.1:${PORT}`));
```

On the other side stands a simplified version of the request layer from the Application
Architecture course. It has one rule: the 5xx class counts as retryable, the 4xx class does
not.

```js
// client.mjs — client that decides based on the status code; counts retries
// Usage: node client.mjs <base-address>
const BASE = process.argv[2];
const JSON_HEADER = { "content-type": "application/json" };

const SCENARIOS = [
  ["read",      "GET",    "/books/978-0131103627", null, null],
  ["missing",   "GET",    "/books/000-0000000000", null, null],
  ["create",    "POST",   "/loans", JSON_HEADER, '{"member":"U-1002","isbn":"978-0131103627","issuedAt":"2026-03-10"}'],
  ["limit",     "POST",   "/loans", JSON_HEADER, '{"member":"U-1002","isbn":"978-0131103627","issuedAt":"2026-03-11"}'],
  ["isbn",      "POST",   "/loans", JSON_HEADER, '{"member":"U-1001","isbn":"000-0000000000","issuedAt":"2026-03-10"}'],
  ["malformed", "POST",   "/loans", JSON_HEADER, "{broken"],
  ["type",      "POST",   "/loans", { "content-type": "text/plain" }, "hello"],
  ["delete",    "DELETE", "/loans/2", null, null],
  ["method",    "DELETE", "/members/U-1001/status", null, null],
];

let totalRequests = 0, silentSuccess = 0;
for (const [name, method, path, headers, body] of SCENARIOS) {
  let attempt = 0, response;
  do {
    attempt++; totalRequests++;
    response = await fetch(BASE + path, { method, headers: headers ?? {}, body });
  } while (response.status >= 500 && attempt < 4);

  const text = await response.text();
  const ok = response.status < 400;
  const bodyHasError = text.includes('"error"');
  if (ok && bodyHasError) silentSuccess++;
  const location = response.headers.get("location");
  console.log(
    `${name.padEnd(9)} ${String(response.status).padEnd(4)} ` +
    `${(ok ? "success" : "error").padEnd(7)} attempt: ${attempt}` +
    (location ? `  location: ${location}` : ""),
  );
}
console.log(`total requests: ${totalRequests}, success-counted errors: ${silentSuccess}`);
```

```bash
# The same client is run against both modes; the request counts are compared.
setup() { rm -f library.db && sqlite3 library.db < schema.sql; }

setup; node code-server.mjs 8477 strict & a=$!; sleep 0.4
echo "--- strict mode ---"; node client.mjs http://127.0.0.1:8477
kill $a

setup; node code-server.mjs 8478 loose & b=$!; sleep 0.4
echo "--- loose mode ---"; node client.mjs http://127.0.0.1:8478
kill $b
```

```
strict mode 127.0.0.1:8477
--- strict mode ---
read      200  success attempt: 1
missing   404  error   attempt: 1
create    201  success attempt: 1  location: /loans/4
limit     409  error   attempt: 1
isbn      422  error   attempt: 1
malformed 400  error   attempt: 1
type      415  error   attempt: 1
delete    204  success attempt: 1
method    405  error   attempt: 1
total requests: 9, success-counted errors: 0
loose mode 127.0.0.1:8478
--- loose mode ---
read      200  success attempt: 1
missing   200  success attempt: 1
create    200  success attempt: 1
limit     500  error   attempt: 4
isbn      500  error   attempt: 4
malformed 500  error   attempt: 4
type      500  error   attempt: 4
delete    200  success attempt: 1
method    500  error   attempt: 4
total requests: 24, success-counted errors: 1
```

## Reading the Measurement

Nine scenarios finished in nine requests under the strict mapping; the loose mapping needed
twenty-four. The difference comes from four retried scenarios that share one trait: **none
of them get fixed by repeating.** The member limit is full, the ISBN is not in the catalog,
the body is malformed, the content type is wrong. Reporting these with 500 tells the client
"I have a temporary problem, try again." The client keeps that promise and tries three more
times — the request count triples, the result does not change.

The second number is sneakier. Under the loose mapping, one failure **counted as success**:
a client asking for a missing book got a 200 and, not reading the body's `error` field,
assumed it had one. An empty book card, an error never logged, a bug never found — all from
this one line.

The third difference is not in the lines but in what is missing. In the strict mapping, the
`create` scenario carries a `Location` header alongside its 201; in the loose mapping, that
header is gone. It is the only place that reports the new resource's address. Without it,
the client has to either read the identity from the body and build the address itself, or
refetch the collection, just to reach the loan record it created.

## Distinguishing Close Codes

The question that drives code selection is this: **which part of the request was not
accepted?**

- **400** — The body could not be read. The request is broken at the format level; the JSON
  did not parse, a required header is missing. The server cannot make sense of the request,
  so it cannot say anything about the content.
- **415** — The body might be readable, but the content type is not supported. The problem
  is not the content, it is the format it was sent in.
- **422** — The body parsed, the fields were read, but the values were not accepted. An ISBN
  not in the catalog is like this: the syntax is right, the sent value is not valid. The
  practical value of this distinction is that the client can trace the error back to the
  form — it can say which field was rejected. Field-level error reporting is a separate
  lesson's subject.
- **409** — Both the body and the values are valid, but the system's current state does not
  allow the request to be fulfilled. A full member limit is exactly this. The difference
  between 422 and 409 is time: 422 is always invalid for the same body, 409 can become valid
  once the state changes.
- **404** — The addressed resource does not exist. The problem is in the path, not the body.
- **405** — The resource exists, the method cannot be applied. The response reports which
  methods are accepted through the `Allow` header; the server above sends `Allow: GET`.
  Confusing this with 404 pushes the client to look in the wrong place: it tries to fix the
  address, when it needs to fix the method instead.

Identity and permission codes — 401 and 403 — were deliberately left off this list. The
distinction between them rests on whether the identity is known, and it is covered in the
Authentication and Authorization course. The only rule here is that neither collapses into
404.

## The 2xx Family

Success, too, is not a single code.

**200** is general success and carries a body. **201** says a new resource was created; it
must carry a `Location` header alongside it, because the client cannot build that address
itself. **204** reports success with no body; it is used for deletions and whenever there is
no new representation to return. **202** says the request was accepted but not yet
completed; it needs a separate resource to track the work's outcome, and it falls under
asynchronous processing.

The selection criterion, again, is the client's decision. A client that gets 204 does not
try to read the body; one that gets 200 does. Returning 200 with an empty body for a delete
forces the client to parse a body that is not there, and produces a body error.

## Collapsing the Unknown into 500

The loose mode's real flaw is not using the 500 code, it is using it **for everything.** 500
is the code the server uses to report its own fault: an unexpected exception, an unreachable
database, a broken assumption. Using it for the client's error breaks two things at once: the
client does not fix a problem it could fix, since it was told the problem is not on its
side; and on the server, real faults mix with client errors and become unmeasurable.

The right behavior is to map every known failure to its own code and reserve 500 for the
case that cannot be classified. The body of an unclassified case gives no detail either;
writing the internal error message outward leaks the server's internal structure into the
contract.

## Summary

- The status code is the part of a response the client reads without interpretation; the
  cache, the request layer, and the retry mechanism decide by looking at it.
- The same nine scenarios finished in nine requests under the strict mapping and produced
  twenty-four under the loose one; the difference comes from errors that will not be fixed
  being reported with 5xx and retried.
- Under the loose mapping, one failure counted as success: a "not found" returned with 200
  turned into an error the client never noticed.
- Error code selection looks at which part of the request was not accepted: 400 for format,
  415 for content type, 422 for field values, 409 for the system's current state, 404 for
  the address, 405 for the method.
- A 201 response is incomplete without a `Location` header; 204 reports success with no body
  and does not force the client to parse one.
- 500 is reserved only for a fault the server cannot classify; used for client errors, it
  misleads the client and makes real faults unmeasurable.

## Next Step

The codes are settled, but what goes inside the response bodies is still open. This lesson
wrote bodies haphazardly: in one place just an identity field, in another an error name and
a limit value. What casing will field names use, how will dates be written, does a missing
field count the same as an empty value, will a collection response be a bare array? The next
lesson takes on body format and shows by running it where wrong choices silently corrupt
data — in parsing large numbers and in date notation.
