Successes get designed. Failures get whatever the framework does by default, which is three different shapes depending on what went wrong. This lesson makes every failure leave as the same JSON body, so a client needs exactly one error parser — and covers a trap in FastAPI's exception handling that produced a genuine bug in the application these posts are drawn from.
What you get by default
Out of the box, three different errors produce three different bodies:
// HTTPException(404, "Not found")
{"detail": "Not found"}
// a validation failure
{"detail":[{"type":"greater_than_equal","loc":["body","guests"],
"msg":"Input should be greater than or equal to 1","input":0,"ctx":{"ge":1}}]}
// an unhandled exception
Internal Server Error // plain text, not JSONdetail is a string in one, a list of objects in another, and the third is not JSON
at all. A frontend has to branch on all three, and it will get it wrong — usually by rendering
[object Object] to a user.
The fix is to decide the shape yourself. StayHub's is two fields, and every failure in the application produces it:
def _body(message: str, field_errors: dict[str, str] | None = None) -> dict:
return {"message": message, "fieldErrors": field_errors or {}}message is always safe to show a user. fieldErrors maps a field name to
a message, and is empty for errors that are not about a field. A client renders the message as a
toast and, if fieldErrors has anything in it, puts each entry next to its input. One
branch, and it is on a field being present rather than on the error's type.
Exceptions the service layer can raise
The base carries a status code and optional field errors:
class ApiException(Exception):
"""Raised anywhere in the service layer; turned into a response by the handler below."""
def __init__(
self,
message: str,
*,
status_code: int = status.HTTP_400_BAD_REQUEST,
field_errors: dict[str, str] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
self.field_errors = field_errors or {}And the common cases get names, so a service says what it means rather than picking a number:
class NotFoundException(ApiException):
def __init__(self, message: str = "Not found") -> None:
super().__init__(message, status_code=status.HTTP_404_NOT_FOUND)
class ConflictException(ApiException):
"""The request was well formed but the world says no — e.g. those dates are taken."""
def __init__(self, message: str) -> None:
super().__init__(message, status_code=status.HTTP_409_CONFLICT)The important property is that these are not HTTPException. A
service raising HTTPException is a service that only works inside a web request —
it cannot be called from a CLI command, a scheduled job or a test without dragging FastAPI along.
A domain exception is portable, and the web layer decides what a given failure means over HTTP.
ConflictException's docstring is the one worth internalising. "The request was well
formed but the world says no" is a category most APIs collapse into 400, and it is genuinely
different: nothing about the request can be fixed, the client should retry or show the user what
changed.
The handlers
@app.exception_handler(ApiException)
async def _api_exception(_: Request, exc: ApiException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code, content=_body(exc.message, exc.field_errors)
)One handler covers the whole hierarchy, because FastAPI matches on the exception class and its subclasses. Adding a new exception type needs no new handler.
The validation handler does more work, and it is the one that pays for itself daily:
@app.exception_handler(RequestValidationError)
async def _validation(_: Request, exc: RequestValidationError) -> JSONResponse:
fields: dict[str, str] = {}
for err in exc.errors():
location = [part for part in err["loc"] if part not in ("body", "query", "path")]
fields[".".join(str(p) for p in location) or "_"] = err["msg"]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=_body("Please check the highlighted fields.", fields),
)Pydantic's loc is a tuple: ("body", "images", 2, "url"). That is
precise and useless to a form, which needs a key it can match against an input. Dropping the source
prefix and joining the rest turns it into "images.2.url" — a path a client can
act on:
{"message":"Please check the highlighted fields.",
"fieldErrors":{"guests":"Input should be greater than or equal to 1",
"images.2.url":"String should have at most 500 characters"}}The or "_" handles a whole-body error, where stripping the prefix leaves nothing.
Never return the reason
logger.exception("Unhandled error", exc_info=exc)
return unhandled_response()Log the real cause, return a generic one. A stack trace in a response body is a gift to whoever is probing the API — it names your framework, your file paths, your ORM and frequently a query with a table name in it.
The same reasoning shows up in messages that are deliberately vague. A login failure never says which half was wrong, because "no such account" and "wrong password" together are an account enumeration tool. Registration is the one place that must admit an email is taken, and that is a considered exception rather than an oversight.
⚠️ The trap: a handler for bare Exception is not where you think
This one produced a real bug, and it is invisible in the source.
Starlette does not put a handler registered for bare Exception in the same place as
the others. Specific handlers live in ExceptionMiddleware, the innermost
layer — inside CORS, inside everything. A handler for Exception instead becomes
ServerErrorMiddleware's handler, the outermost layer of the entire
stack:
ServerErrorMiddleware <- your @app.exception_handler(Exception) lives HERE
└─ CORSMiddleware
└─ your other middleware
└─ ExceptionMiddleware <- every other handler lives here
└─ routerA response built out there has already skipped every user middleware on the way back. Measured against StayHub before the fix:
unhandled 500 -> X-Request-ID absent Access-Control-Allow-Origin absent
handled 404 -> X-Request-ID present Access-Control-Allow-Origin presentThe consequence is not cosmetic. A browser shown a 500 with no
Access-Control-Allow-Origin reports a CORS failure and never exposes the body,
so the frontend's error parser never saw the message the API had carefully written. Every server
error looked like a network problem.
The fix is to catch it somewhere inside CORS. StayHub's request middleware converts it and returns the response itself:
response = unhandled_response()
response.headers[REQUEST_ID_HEADER] = request_id
response.headers["X-Response-Time-Ms"] = f"{elapsed_ms:.1f}"
request_id_ctx.reset(token)
return responseBecause it returns rather than re-raises, CORSMiddleware — which is outside it — sees an ordinary response and adds its headers. The body still comes from one place:
UNHANDLED_MESSAGE = "Something went wrong on our end."
def unhandled_response() -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=_body(UNHANDLED_MESSAGE)
)The @app.exception_handler(Exception) stays registered as a backstop for anything
that escapes before the middleware is reached. Two paths, one body.
Documenting the failures
FastAPI infers the success response from response_model, and knows nothing about
the errors. Left alone, /docs shows a 200 and a 422 and implies that is the whole
story.
Declaring the shape once makes it visible:
class ErrorBody(ApiModel):
"""Documents the shape produced by core/exceptions.py, so it shows up in Swagger."""
message: str
field_errors: dict[str, str] = {}Then attach it where it matters — per route, per router, or globally:
@router.post(
"",
response_model=BookingResponse,
status_code=status.HTTP_201_CREATED,
responses={
409: {"model": ErrorBody, "description": "Those dates are no longer available"},
404: {"model": ErrorBody, "description": "Listing not found"},
},
)That is not decoration. A generated client reads it and produces a typed error branch; without it, every failure is an untyped blob. The 409 in particular is the one a caller must handle deliberately, and an undocumented 409 is a caller that treats it as a generic failure and retries forever.
Do not enumerate every possible code on every route. Document the ones a client has to act differently on.
Is HTTPException ever fine?
Yes — in the web layer, for something that is genuinely an HTTP concern:
@app.get("/legacy")
def legacy():
raise HTTPException(status_code=410, detail="This endpoint was removed in v2.")"This URL is gone" is not a domain rule. Nothing in a service layer needs to know about it, and
inventing a GoneException for one route is ceremony.
The line worth holding: routes may raise HTTPException; services may
not. If you do use it alongside a custom shape, register a handler for it too, or you have
reintroduced the two-shapes problem for exactly the errors you thought were trivial:
@app.exception_handler(StarletteHTTPException)
async def _http_exception(_: Request, exc: StarletteHTTPException) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=_body(str(exc.detail)))That also catches the 404 Starlette raises for an unmatched route, which otherwise returns
{"detail":"Not Found"} — the one error most likely to reach a confused
client.
Translating errors from below
Errors from a library are rarely fit to return. The pattern is to catch, classify and re-raise as a domain exception:
except IntegrityError as exc:
self.db.rollback()
if _is_overlap_violation(exc):
raise ConflictException(
"Those dates were just booked by someone else."
) from exc
raiseThree things are right here. It inspects which constraint failed rather than assuming,
so an unrelated integrity error is not mislabelled as a date clash. It re-raises anything it does
not recognise, rather than swallowing it. And from exc preserves the original as
__cause__, so the logged traceback still shows the Postgres error underneath the
friendly message.
Without this, a booking race is a 500. With it, it is a 409 saying exactly what happened.
Messages worth writing
An error message is a piece of interface. Compare:
"Invalid request"
"Booking validation failed: constraint violation on field guests"
"This place sleeps up to 4 guests."
"This booking can no longer be cancelled — the deadline was 02 Sep 2026,
2 days before check-in."The second pair tell the user what the rule is and what to do. The first pair tell them something went wrong and leave them to guess. Both cost the same to write:
raise ApiException(
f"This booking can no longer be cancelled — the deadline was {deadline:%d %b %Y}, "
f"{settings.cancellation_cutoff_days} days before check-in."
)Two rules keep them safe. Say what the rule is, not what the code did — "constraint violation" is your problem, not theirs. And never interpolate anything the user did not already know; a message that helpfully includes another account's email is a leak wearing a friendly face.
Where an error is allowed to be a lie
One deliberate case, mentioned in earlier lessons and worth stating plainly here:
if booking.guest_id != actor.id and actor.role != "ADMIN":
raise NotFoundException("Booking not found.")That booking exists. The caller is being told it does not, because a 403 would confirm the id is real — and on a guessable identifier, a 403/404 difference is an enumeration oracle. The honest status code leaks; the misleading one does not.
Use it for resources owned by someone else. Do not use it for permissions on a documented surface: StayHub's admin API returns a straight 403, because its existence is not a secret and pretending it is only confuses staff.
Which errors a client should retry
The status code is a contract about what to do next, and getting it wrong makes clients behave badly in ways you never see.
| Code | Retry? | Why |
|---|---|---|
| 400 / 422 | never | the request is wrong; sending it again changes nothing |
| 401 | after refreshing the token | a retry with the same token loops |
| 403 | never | permissions will not change on their own |
| 404 | never | — |
| 409 | only after re-reading state | the world moved; the client must look again |
| 429 | yes, after Retry-After | that is what the header is for |
| 500 | yes, with backoff | may be transient |
| 503 | yes, with backoff | explicitly temporary |
The two worth being careful about are 400 and 500. Returning 400 for a server-side failure means clients never retry something that would have succeeded. Returning 500 for a bad request means clients retry something that can never succeed, and your error rate becomes their retry loop.
The rule of thumb: 4xx means the caller must change something; 5xx means we must. A validation failure caused by our own bad default is a 5xx, however much it looks like a 400.
Errors you can actually find again
An error body that is safe to show a user is, by design, not enough to debug with. The bridge is a correlation id returned to the client and logged with the failure:
logger.exception(
"%s %s failed after %.1fms",
request.method,
request.url.path,
elapsed_ms,
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status": 500,
"duration_ms": round(elapsed_ms, 1),
},
)The same id goes back on the response as X-Request-ID. A user reporting "it broke"
can be asked for it, or a frontend can display it on its error screen, and one search finds the
exact request with its full traceback. Without that, "a 500 at about four o'clock" is the entire
bug report.
logger.exception rather than logger.error — it attaches the
traceback automatically. Lesson 15 covers making these lines machine-readable.
A note on Problem Details
There is a standard for this: RFC 9457 (formerly 7807), which defines a JSON
body with type, title, status, detail and
instance, served as application/problem+json.
{"type":"https://example.com/probs/dates-taken",
"title":"Those dates are no longer available",
"status":409,
"detail":"2027-03-01 to 2027-03-04 overlaps an existing booking.",
"instance":"/api/v1/bookings"}It is worth knowing and worth using if your API is consumed by parties you do not control,
because a standard shape means their tooling already understands it. StayHub uses its own two-field
body instead, for a specific reason: fieldErrors maps directly onto form inputs, and
that is what its two React apps actually need. Problem Details has no first-class notion of
per-field errors.
Either is defensible. What is not defensible is three shapes, which is what you get by making no decision at all.
Test the error paths
Errors are the least-exercised code in most applications and the most-seen by users. They deserve tests:
def test_it_returns_the_standard_error_body(self, client, probe_routes):
r = client.get("/__test/boom")
assert r.status_code == 500
assert r.json() == {"message": "Something went wrong on our end.", "fieldErrors": {}}
def test_it_never_leaks_the_exception(self, client, probe_routes):
r = client.get("/__test/boom")
assert "RuntimeError" not in r.text
assert "deliberate" not in r.text
assert "Traceback" not in r.textThe second is a security test written as an assertion about a string, which is the right level — it fails if anybody changes the handler to be more helpful.
And the regression test for the trap above, which is the reason any of this is written down:
def test_a_500_still_carries_cors_headers(self, client, probe_routes):
"""⚠️ THE regression test. Reordering the two add_middleware calls in main.py breaks this
and nothing else in the suite."""
r = client.get("/__test/boom", headers={"Origin": ORIGIN})
assert r.status_code == 500
assert r.headers.get("access-control-allow-origin") == ORIGINThat test was verified by reintroducing the bug: swapping the two middleware registrations fails it and nothing else. A regression test nobody has watched fail is a guess.
What the other side does with it
The shape only pays off if a client can act on it mechanically. One parser, used everywhere:
export type ApiError = { message: string; fieldErrors: Record<string, string> }
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, init)
if (res.ok) return res.json()
// Every failure from this API has the same two fields — including the 500s,
// which is only true because the middleware catches them inside CORS.
const err: ApiError = await res.json().catch(() => ({
message: 'Something went wrong.', fieldErrors: {},
}))
throw Object.assign(new Error(err.message), {
status: res.status,
fieldErrors: err.fieldErrors,
requestId: res.headers.get('X-Request-ID'),
})
}A form then does the obvious thing: show message as a toast, and hand
fieldErrors to the form library to render beside each input. No branching on status
codes to work out where the message is.
The .catch() matters. It is the one place a non-JSON body can still arrive —
a 502 from a load balancer, a 413 from nginx before the request ever reached Python. Those are not
yours to shape, so the client needs a fallback regardless.
Failing at startup
One class of error is not a response at all. If a required setting is missing or a dependency is unreachable, the question is whether to refuse to start.
The distinction worth drawing: refuse for things that make the application wrong, degrade for things that make it partial.
A missing JWT secret means every token is signed with a default that is public knowledge — refuse. Typed settings do this for free, since a field with no default cannot be constructed without a value.
A slow search cluster means search does not work yet. Everything else does, so refusing is the wrong call:
⚠️ Creating the search index here must NOT be able to stop the app booting. If Elasticsearch
is slow to start — and it always is, it is a JVM — a hard failure here means the API is down
because *search* is not ready. Everything except search works fine without it.The endpoint then owns its own degradation, and says so honestly rather than returning an empty list that looks like "no results":
if not es_available():A 503 with a real message tells the client to retry. Zero results tells it there is nothing to find, which is a lie that ends up in a bug report about missing listings.
Errors that are not exceptions
One shape worth knowing, because it applies where exceptions do not fit: an operation that partially succeeds.
Raising means abandoning the whole request, which is right when nothing useful happened and wrong for a bulk operation where nineteen of twenty items worked. There the failures are data, not control flow:
{"results": [
{"id": "a1b2...", "ok": true},
{"id": "c3d4...", "ok": false, "error": {"message": "Listing not found.", "fieldErrors": {}}}
]}Note the per-item error reuses the same two-field body. That is the discipline paying off: one shape, whether it is the whole response or nested inside one.
The same reasoning covers a slower case — work that fails after the response has been sent. A background task has no caller to raise to, so its failure is a log line and, if it matters, a row somewhere recording that the thing did not happen. Lesson 11 covers why that changes how the task is written.
Making failures easy to reproduce
A useful habit when adding a new error: add the route that triggers it, in the test suite, at the same time.
@app.get("/__test/boom", include_in_schema=False)
def _boom():
raise RuntimeError("deliberate — testing the unhandled path")include_in_schema=False keeps it out of the documentation, and the fixture removes
it afterwards. Having a deliberate way to produce each class of failure is what makes the handler
testable at all — and it is how the CORS-on-500 bug was found, fixed and then locked in.
The checklist
- One body shape, everywhere. Decide it, then never produce another.
- Domain exceptions in services;
HTTPExceptionstays in the web layer, if at all. - Flatten validation errors into
field → message. - Log the cause, return a generic message, never a traceback.
- Catch a bare
Exceptioninside your middleware, not only via@app.exception_handler(Exception). - Translate library errors into domain ones, and re-raise what you do not recognise.
- 404 for somebody else's resource; 403 only where the surface is public knowledge.
- Write messages a user can act on.
Next: authentication and authorization — where most of these exceptions get raised, and the JWT behind them.