Skip to content
academia.sh

Lesson 11 / 34

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.

Contents

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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close