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 formurn:problem:<service>:<code>or a URL to a documentation page. The same kind of error always has the sametype.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 inUPPER_SNAKE_CASEfor 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
422code is non-standard for validation — the correct code is400 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
Responsedirectly withmedia_type="application/problem+json", notJSONResponse. - We collect all violations from
exc.errors()— not just the first one. - The
_map_locfunction 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
| Code | When to use |
|---|---|
400 Bad Request | malformed body, validation errors |
401 Unauthorized | no token or token expired |
403 Forbidden | token present, but insufficient permissions |
404 Not Found | object by ID not found |
409 Conflict | conflict on concurrent modification, duplicate |
410 Gone | endpoint removed and no longer exists |
429 Too Many Requests | request rate limit exceeded |
500 Internal Server Error | unexpected 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 optionallyviolations. - Error responses are returned with
Content-Type: application/problem+json. - FastAPI returns
422on a validation error by default — you need to override the handler to400. type— a stable URN or URL, always concrete, neverabout:blank.code—UPPER_SNAKE_CASEfrom aStrEnum, needed by the client for programmatic logic.- In
violationswe return all violations at once, with the field path in dot-notation. - We don't include the call stack or SQL in a
500body — onlytraceId.
What to read next
- JSON and response formats in FastAPI — the structure of successful responses.
- Headers and tracing in FastAPI — how
traceparentbecomestraceId. - 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.