Skip to content
Chapter 07 · 15 minutes

Errors that help

One error format for everything: clear for clients, safe for you.

Goal

Return errors people can act on, in one consistent shape, without leaking internals.

One shape for every error

Shelf follows RFC 9457, Problem Details. Every error looks like this, with the content type application/problem+json:

{
  "type": "about:blank",
  "title": "Book not found",
  "status": 404,
  "detail": "book 999 not found",
  "request_id": "6f1c0e2b9a8d4f7e8c3b2a1d0e9f8a7b"
}

request_id matches the X-Request-ID header and the log line for that request (chapter 10), so a bug report can be traced in seconds.

Raise a meaningful exception

Routes do not build error responses by hand. They raise:

def get_book_or_404(session: Session, book_id: int) -> Book:
    book = session.get(Book, book_id)
    if book is None:
        raise NotFoundError("book", book_id)
    return book

Handlers turn exceptions into responses

app/errors.py registers one handler per kind of error:

@app.exception_handler(NotFoundError)
async def not_found(request: Request, exc: NotFoundError) -> JSONResponse:
    return problem(request, 404, f"{exc.resource.capitalize()} not found", str(exc))
  • Validation errors (422) list every field that needs attention, with a plain message.
  • HTTP errors (401, 405, ...) keep their status and headers.
  • Anything unexpected becomes a calm 500:
@app.exception_handler(Exception)
async def unexpected(request: Request, exc: Exception) -> JSONResponse:
    # Log the type only: messages can contain user data. The request id links log and reply.
    log.error("unhandled error", extra={"error": type(exc).__name__, ...})
    return problem(request, 500, "Something went wrong on our side")

The client never sees a stack trace, a SQL statement or a file path.

Check it works

curl -i http://127.0.0.1:8000/books/999

Look at the status line, the content-type and the x-request-id header, and compare the id with the one in the body.

Exercise

Add a ConflictError and use it in create_book to refuse a second book with exactly the same title and author (status 409, title "Book already on your shelf").