← Back to the section

When an API returns an error, the client needs to understand: what went wrong, why, and what to do about it. If every service invents its own format, client code turns into a pile of unique hacks for each endpoint.

RFC 9457 solves this problem: the standard describes a single format for the error body called Problem Details. FastAPI doesn't use it out of the box — you'll have to set it up.

What Problem Details is

Problem Details is a JSON object with a specific set of fields:

{
  "type": "urn:problem:order-service:order-not-found",
  "status": 404,
  "title": "Not Found",
  "detail": "Order a1b2c3 not found",
  "instance": "urn:uuid:9f2d6c22-...",
  "traceId": "00-1f2a8b6c...",
  "code": "ORDER_NOT_FOUND"
}
  • type — a stable identifier of the error category. Usually a URN of the form urn:problem:<service>:<code> or a URL to a documentation page. The same kind of error always has the same type.
  • status — the HTTP code (duplicated in the body for easier parsing).
  • title — a short human-readable name matching the HTTP code.
  • detail — a specific description of this particular error.
  • code — a machine-readable code in UPPER_SNAKE_CASE for programmatic logic on the client.
  • traceId — the request identifier for looking things up in the logs.

Error responses are served with the header Content-Type: application/problem+json, not the usual application/json.

Why FastAPI needs to be reconfigured

By default FastAPI returns 422 Unprocessable Entity on a Pydantic validation error. The body then looks like this:

{
  "detail": [
    {
      "loc": ["body", "amount"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

Problems with this format:

  • The 422 code is non-standard for validation — the correct code is 400 Bad Request.
  • The structure doesn't match RFC 9457 — the client can't handle errors uniformly.
  • The Pydantic v1 and v2 formats differ — upgrading breaks the client.

The solution is to override both of FastAPI's exception handlers.

Data models

First let's describe the structures with Pydantic:

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class Violation(BaseModel):
    field: str | None = None
    message: str

class ProblemDetail(BaseModel):
    type: str
    status: int
    title: str
    detail: str
    instance: str | None = None
    trace_id: str | None = None
    code: str
    violations: list[Violation] | None = None

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

alias_generator=to_camel automatically turns trace_id into traceId in the JSON.

The validation error handler (422 → 400)

FastAPI raises RequestValidationError when the request body doesn't match the Pydantic schema. Let's override the handler:

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import Response
import json

app = FastAPI()

def _map_loc(loc: tuple) -> str:
    parts = [str(p) for p in loc if p != "body"]
    return ".".join(parts)

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request, exc: RequestValidationError
) -> Response:
    violations = [
        {"field": _map_loc(e["loc"]), "message": e["msg"]}
        for e in exc.errors()
    ]
    body = {
        "type": "urn:problem:order-service:validation-error",
        "status": 400,
        "title": "Bad Request",
        "detail": "Input data validation error",
        "code": "VALIDATION_ERROR",
        "traceId": getattr(request.state, "trace_id", None),
        "violations": violations,
    }
    body = {k: v for k, v in body.items() if v is not None}
    return Response(
        content=json.dumps(body, ensure_ascii=False),
        status_code=400,
        media_type="application/problem+json",
    )

Key points:

  • We return Response directly with media_type="application/problem+json", not JSONResponse.
  • We collect all violations from exc.errors() — not just the first one.
  • The _map_loc function strips the extra "body" prefix and turns the path into dot-notation.

Domain exceptions

For business-logic errors we create a base class and specific exceptions:

from enum import StrEnum

class ErrorCode(StrEnum):
    ORDER_NOT_FOUND = "ORDER_NOT_FOUND"
    ORDER_EMPTY = "ORDER_EMPTY"
    INSUFFICIENT_STOCK = "INSUFFICIENT_STOCK"
    RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
    VALIDATION_ERROR = "VALIDATION_ERROR"
    INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"

class DomainError(Exception):
    def __init__(self, code: ErrorCode, detail: str, status: int = 400):
        self.code = code
        self.detail = detail
        self.status = status

class OrderNotFoundError(DomainError):
    def __init__(self, order_id: str):
        super().__init__(
            code=ErrorCode.ORDER_NOT_FOUND,
            detail=f"Order {order_id} not found",
            status=404,
        )

And a handler for them:

import uuid

HTTP_STATUS_TITLES = {
    400: "Bad Request",
    401: "Unauthorized",
    403: "Forbidden",
    404: "Not Found",
    409: "Conflict",
    410: "Gone",
    429: "Too Many Requests",
    500: "Internal Server Error",
}

@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError) -> Response:
    body = {
        "type": f"urn:problem:order-service:{exc.code.lower().replace('_', '-')}",
        "status": exc.status,
        "title": HTTP_STATUS_TITLES.get(exc.status, "Error"),
        "detail": exc.detail,
        "instance": f"urn:uuid:{uuid.uuid4()}",
        "code": exc.code,
        "traceId": getattr(request.state, "trace_id", None),
    }
    body = {k: v for k, v in body.items() if v is not None}
    return Response(
        content=json.dumps(body, ensure_ascii=False),
        status_code=exc.status,
        media_type="application/problem+json",
    )

The unexpected error handler

For everything not caught above — a separate handler:

@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception) -> Response:
    body = {
        "type": "urn:problem:order-service:internal-error",
        "status": 500,
        "title": "Internal Server Error",
        "detail": "Internal server error",
        "code": "INTERNAL_SERVER_ERROR",
        "traceId": getattr(request.state, "trace_id", None),
    }
    body = {k: v for k, v in body.items() if v is not None}
    return Response(
        content=json.dumps(body, ensure_ascii=False),
        status_code=500,
        media_type="application/problem+json",
    )

Important: never include the call stack, SQL queries, or internal filesystem paths in a 500 body. Only traceId — you can use it to find the details in the logs. This protects against accidentally leaking implementation details to the client.

The violations field — the list of validation violations

When a request has several errors, return them all in a single response. The path to the field is passed in dot-notation:

def _map_loc(loc: tuple) -> str:
    parts = []
    for p in loc:
        if p == "body":
            continue
        parts.append(str(p))
    return ".".join(parts)

Example of mapping Pydantic paths:

("body", "delivery_address", "zip_code")  →  "deliveryAddress.zipCode"
("body", "items", 0, "quantity")          →  "items.0.quantity"

The resulting JSON:

{
  "violations": [
    { "field": "amount", "message": "Amount must be greater than 0" },
    { "field": "deliveryAddress.zipCode", "message": "Postal code is required" },
    { "field": "items.0.quantity", "message": "Quantity must be from 1 to 99" }
  ]
}

Which HTTP codes to use

CodeWhen to use
400 Bad Requestmalformed body, validation errors
401 Unauthorizedno token or token expired
403 Forbiddentoken present, but insufficient permissions
404 Not Foundobject by ID not found
409 Conflictconflict on concurrent modification, duplicate
410 Goneendpoint removed and no longer exists
429 Too Many Requestsrequest rate limit exceeded
500 Internal Server Errorunexpected server error

The 422 code is not used for request validation — only 400. Non-standard codes (418, 451) aren't used either: clients don't know them and don't handle them.

Common mistakes

Content-Type: application/json instead of application/problem+json. Errors are a special kind of response and require a separate media type. The client can automatically route responses with problem+json to an error handler.

type: "about:blank". This is a special RFC value meaning "type not specified". The client can't programmatically distinguish error categories. Always specify a concrete URN or URL.

Only the first validation error. The user fixes one field, sends the request again, and gets the next error. It's better to return all violations at once via violations.

A call stack in the detail of a 500 error. This exposes the internal structure of the application. Log the details on the server; give the client only traceId.

In short

  • RFC 9457 Problem Details — the standard error body format: type, status, title, detail, code, and optionally violations.
  • Error responses are returned with Content-Type: application/problem+json.
  • FastAPI returns 422 on a validation error by default — you need to override the handler to 400.
  • type — a stable URN or URL, always concrete, never about:blank.
  • codeUPPER_SNAKE_CASE from a StrEnum, needed by the client for programmatic logic.
  • In violations we return all violations at once, with the field path in dot-notation.
  • We don't include the call stack or SQL in a 500 body — only traceId.
  • JSON and response formats in FastAPI — the structure of successful responses.
  • Headers and tracing in FastAPI — how traceparent becomes traceId.
  • Rate limiting, files, deprecation — the 429 and 410 codes in practice.
  • REST API errors — the Problem Details format — a language-neutral overview of the format.