Errors as a Contract
An error response is a message to a program, and {"error": "something went wrong"} tells the program nothing it can act on. Stagedoor's first API had that body on 500s, {"error": "seat taken"} on some 409s and {"message": "Seat is taken"} on others, and the web client string-matched the second until a rewording broke it on the day of the spring on-sale. RFC 9457 Problem Details gives every error one shape, a type, a title, a status, a detail, an instance and room for the fields a client needs, and this topic adopts it for every non-2xx the service sends.
The second half of the topic is what an error reveals. A response that says too much is a map of the codebase in every 500, or a user directory in every login failure. The wrong 404 tells an attacker which emails have accounts, one request per email, at the rate of the rate limiter. The service decides what each error tells the client, and the rule is that the error says what to do next, never what the server is made of.
One Shape for Every Error
The content type is application/problem+json, and the body has five standard members. type is a URI that names the class of error and is the field a client switches on. title is a short human summary of the class, the same for every occurrence. status repeats the HTTP status so the body is self-describing when it is logged without its headers. detail describes this occurrence for a human. instance identifies this occurrence, and Stagedoor puts the request id there. Anything else the client needs is an extension member beside them.
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Cache-Control: no-store
{
"type": "https://stagedoor.example/problems/seat-held",
"title": "Seat is held by another buyer",
"status": 409,
"detail": "Seat 14C is held until 19:42.",
"instance": "/req/01J9V3M8QK7R",
"held_until": "2026-10-04T19:42:10Z"
}
The response is a conflict for seat 14C. The type names the class, seat-held, and it is a URL because a URL is globally unique and can point at documentation. The detail says which seat and until when, in English, for a support engineer reading a log. The extension held_until is the same fact as a timestamp, for the checkout page to show a countdown. A client switches on type, reads held_until, and never parses the detail, because the detail is prose and prose is rewritten.
Codes vs Messages
type: "https://stagedoor.example/problems/seat-held" is stable, documented and part of the contract; renaming it is a breaking change under Topic 17. detail: "Seat 14C is held until 19:42" is for a human and may change in any release without notice. The web client that broke on the on-sale night had switched on the message. When "seat taken" became "Seat is taken," its branch for the held-seat case stopped matching, it fell through to the generic error, and 2,000 buyers saw "something went wrong" instead of "try another seat." The catalogue of types is a page in the API document, one entry per class, with the status it comes with and the extensions it carries. A new type can be added at any time; a client that meets an unknown one falls back on the status code, which Chapter 2 made sufficient on its own.
Validation Errors Carry the Fields
A 422 lists every failing field, all at once, so a form can highlight each one and the user fixes them in a single round trip. Topic 14 produces the list at the boundary; this topic gives it a shape. The type is problems/validation, and the extension is errors, an array with one entry per field: the field's path, a stable reason code, and a human message. Stopping at the first failure is the mistake that makes a three-field form round-trip three times, and the buyer gives up on the second.
What an Error Reveals
Every error is also an answer to a question the client may not have been entitled to ask. A login endpoint that returns "no such user" for an unknown email and "wrong password" for a known one is an account-enumeration oracle: one request per email address, and the attacker leaves with a list of everyone who has an account. The login error is "invalid email or password" for both cases, same status, same body, same response time within noise. A 500 whose body carries the stack trace is a map of the codebase: file paths, library versions, the line of SQL that failed, and the name of the internal host it failed on. A 404 on GET /users/{email} that differs in any way from the 404 for an unknown email is the oracle again, one layer down.
An error tells the client what to do, not what the server is made of. "Invalid email or password" tells the client to try again. "Seat 14C is held until 19:42" tells the client to try another seat. "Internal error, request id such and such" tells the client to retry later and gives support a handle. None of those says which framework, which host, or which emails exist. Where a client could act on more, the more is an extension member, chosen on purpose, and never the raw exception.
404 vs 403, Chosen on Purpose
An order that exists and belongs to another buyer returns 404, not 403. A 403 says "this exists and you may not have it," which confirms the existence of an order at that id and, with a sequence id, would have confirmed every order in the range; the UUID of Topic 12 makes guessing hard and the 404 makes a correct guess worthless. An event that an organizer may not edit returns 403, because events are public listings, hiding one buys nothing, and a 404 would send the organizer's client to a "not found" page for an event it just displayed. The decision is per resource, made once, and written beside the route so the next engineer does not re-decide it by accident. Chapter 5 records the full table when the authorization layer exists.
Errors Are Logged Once and Correlated
The 500's detail is "internal error" and its instance is the request id. The full exception, with its trace, its SQL and its host, goes to the log under that same id, which Chapter 13 makes the key of every log line in the request. Support finds the trace by the id in ten seconds; the client cannot read it at all. The exception is logged exactly once, at the boundary handler that turns it into the 500, not at every layer it passed through on the way up, because a trace logged four times is four alerts for one bug and a log that is 80 percent duplicates.
async def unhandled(request: Request, exc: Exception) -> Response: rid = request.state.request_id log.exception("unhandled", request_id=rid, path=request.url.path) # once, here, with the trace return problem( status=500, type="https://stagedoor.example/problems/internal", title="Internal error", detail="The request failed. Quote the instance id to support.", instance=f"/req/{rid}", )
The handler logs the exception with its trace under the request id, then returns a Problem Details body that carries the id and nothing else about the failure. A buyer who writes to support quotes the id, support searches the log for it, and the trace is there with every line the request logged before it failed. The framework helper that exists for this in FastAPI is an exception handler registered for the base exception class; every framework in the comparable-tools row has the same hook under a different name.
- Free-text errors with no type — the client string-matches "seat taken," the message is reworded to "Seat is taken," and 2,000 buyers at on-sale see the generic error instead of "try another seat."
- Different errors for "no such user" and "wrong password" — the login form is a user directory at one request per email, and the rate limiter is the only thing slowing the enumeration down.
- Stack traces in production responses — file paths, library versions, a line of SQL and an internal hostname in every 500, handed to whoever triggered it.
- Stopping at the first validation failure — the three-field form round-trips three times, the buyer gives up on the second, and the seat hold expires while she is retyping.
- 403 where 404 was the design — the client learns that the order exists and belongs to someone, which with a guessable id is a confirmed list of other people's orders.
- Logging the exception at every layer it passes through — one bug becomes four alerts and a log that is mostly the same trace repeated, and the request id is on none of them.
- Return Problem Details for every non-2xx, with a stable
typeper error class and a documented catalogue of types, statuses and extensions. - Report every validation failure at once in an
errorsarray keyed by field, with a stable reason code beside the human message. - Decide 404 versus 403 per resource by asking whether existence is the secret, and write the decision beside the route.
- Return one identical error for every login failure, and never let a 404 on a user resource differ from the 404 for a user that does not exist.
- Log the full exception once, at the boundary handler, under the request id, and return only the id and a generic detail in the 500.
Knowledge Check
The checkout page needs to show "available again in 6 minutes" when a seat is held. Which member of the 409 body may it read, and which must it not?
- It may parse the time out of the detail, since that is what the detail is for, and must ignore type
- It may read the title to find the seat and the time, and must not rely on the status code
- It may read the instance, which encodes the expiry, and must not read any extension member
- It may switch on type and read the held_until extension, and must never parse the detail string
Why does the login endpoint return the same body for an unknown email and for a wrong password?
- A difference would let anyone learn which emails have accounts
- Problem Details allows only one type per status code, so both share it
- Both failures are a 401, and a 401 body must not name the credential
- The server cannot tell the two cases apart once the password is hashed
A buyer requests another buyer's order by its UUID. Stagedoor answers 404. An organizer tries to edit an event she does not own and gets 403. What decides the difference?
- Whether the request carried a valid token, since 403 needs one and 404 does not
- Whether the existence of the resource is itself the secret, decided per resource type
- Whether the request was a read or a write, since writes get 403 and reads get 404
- Whether the id is a UUID or a sequence, since UUIDs are hidden and sequences are visible
An unhandled exception occurs in POST /orders. Where does the stack trace go, and what does the client receive?
- The trace goes in the 500 body so the client's developer can debug it; the log gets a one-line summary
- The trace is logged at every layer it passed through; the client gets a 500 with the request id
- The trace goes to the log once under the request id; the client gets a generic 500 with that id
- The trace goes to the log and the client gets a 200 with an error member, so no retry is triggered
You got correct