Errors a Client Can Act On

One error shape, a stable machine code beside the human message, the right status, and never a traceback.

Syntax{"error": {"code": "order_not_found", "message": "...", "request_id": "..."}}

An error response is an API feature. Something on the other side has to decide whether to retry, prompt the user, or give up, and it can only do that if your errors are consistent and specific.

One shape, everywhere

Pick an envelope and use it for every failure: a stable machine-readable code, a human message, optional per-field details, and a request id the caller can quote in a support ticket. The code is the contract — order_already_paid can be matched in a client if and will not change when someone rewords the message. Statuses alone are too coarse: three different things return 409, and only the code says which. RFC 9457 standardises a version of this as application/problem+json, with type, title, status and detail; use it or your own shape, but use one.

Choosing the status

400 means the request was malformed or unparseable. 404 means the resource does not exist. 409 means the request was valid but conflicts with current state — already paid, already cancelled, a duplicate key. 422 means it parsed and the values were wrong. 401 is "I do not know who you are", 403 is "I know, and no".

Never return a traceback

A traceback discloses file paths, library versions, SQL and sometimes argument values, and it tells a client nothing it can act on. Log it and return a generic 500 with the request id, so you can find the real thing. logging.exception(msg) is shorthand for logging.error(msg, exc_info=True), and it is valid only inside an except block. The matching rule for servers: never run with debug on in production — Flask's debug mode serves an interactive console.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Giving a mobile client something it can branch on instead of parsing your prose.
  • Stopping a 500 from leaking file paths and SQL to the internet.
  • Choosing between 409 and 422 for an endpoint that changes state.
  • Tracing one user's failed request through your logs from the id in their screenshot.

More examples

One handler for the whole app

Register this once and every handler can simply raise. The two-handler split is the important part: known failures map to their status, and anything unexpected gets logged with an id and reduced to a generic 500, so a new exception type can never start leaking internals.

Example · python
import logging
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

log = logging.getLogger(__name__)
app = FastAPI()

class AppError(Exception):
    status, code = 500, "internal_error"
    def __init__(self, message, **details):
        super().__init__(message)
        self.details = details

class OrderNotFound(AppError):
    status, code = 404, "order_not_found"

@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError):
    body = {"error": {"code": exc.code, "message": str(exc)}}
    if exc.details:
        body["error"]["details"] = exc.details
    return JSONResponse(status_code=exc.status, content=body)

@app.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception):
    request_id = str(uuid.uuid4())
    log.exception("unhandled error request_id=%s path=%s", request_id, request.url.path)
    return JSONResponse(status_code=500, content={
        "error": {"code": "internal_error", "message": "an unexpected error occurred",
                  "request_id": request_id}})

The 409 nobody plans for

The distinction that makes retries safe. Keep it in mind when designing any endpoint that changes state — a client that cannot distinguish 'already done' from 'cannot be done' will either retry for ever or give up on a success.

Example · python
# 422 and 409 both reject a well-formed request, for different reasons.
#   422: the VALUES are wrong          -> the client can fix the body
#   409: the values are fine, the STATE is wrong -> the client must do something else
def pay(order, amount_cents):
    if amount_cents < 1:
        raise Invalid("amount must be at least 1", field="amount_cents")   # 422
    if order["status"] == "paid":
        # Retrying a payment that already succeeded must not charge twice, and
        # the client needs to tell these apart, so the code carries the meaning.
        raise Conflict("order is already paid", current_status="paid")     # 409
    if order["status"] == "cancelled":
        raise Conflict("order was cancelled", current_status="cancelled")  # 409
    return {"status": "paid"}

# Two 409s, two codes: order_already_paid and order_cancelled. A client matching
# on the status alone cannot write different behaviour for them.

RFC 9457 problem details

Use the standard when you are publishing an API to people you will never meet, because their tooling may already understand it. The `type` URI doubles as documentation: make it a page that actually explains the error.

Example · python
from fastapi.responses import JSONResponse

def problem(status, code, title, detail, instance, **extra):
    # RFC 9457: type/title/status/detail/instance, plus any extension members.
    # The media type matters -- clients detect the format from it.
    return JSONResponse(
        status_code=status,
        media_type="application/problem+json",
        content={"type": f"https://api.example.com/problems/{code}",
                 "title": title, "status": status,
                 "detail": detail, "instance": instance, **extra},
    )

# From a handler, you would return it:
response = problem(409, "order-already-paid", "Order already paid",
                   "Order 1041 was paid at 2026-02-01T10:03:00Z",
                   instance="/v1/orders/1041/payments", current_status="paid")

Discussion

  • Be the first to comment on this lesson.