Errors and Versioning
22 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Clients will meet errors, and they will keep running for years while your API changes. A steady error shape lets a client react without guessing. Versions let you improve the API without breaking those clients.
Errors need a shape
Error response is the body an API sends with a 4xx or 5xx status. A good one has a stable machine-readable code, a human message and, when useful, details such as which field was wrong.
Pick one error body and use it everywhere. Give a short machine code that programs can check, a message for humans, and optional details. The HTTP status code stays the first signal.
def make_error(code, message, details=None): return {"error": {"code": code, "message": message, "details": details or []}}
print(make_error("not_found", "Order 9 does not exist"))Example 1 · A shared error shape
def make_error(code, message, details=None): return {"error": {"code": code, "message": message, "details": details or []}}
print(make_error("not_found", "Order 9 does not exist"))print(make_error("validation_failed", "Invalid request", [{"field": "price", "type": "missing"}])){'error': {'code': 'not_found', 'message': 'Order 9 does not exist', 'details': []}}
{'error': {'code': 'validation_failed', 'message': 'Invalid request', 'details': [{'field': 'price', 'type': 'missing'}]}}What this code does
- Every error has the same three keys, so clients need one parser.
- The details list says which field failed.
A client can test error.code and never has to parse the message text.
Handle errors in one place
Raise your own exception in the business code. One handler turns it into the shared error body. FastAPI also lets you replace the default 422 reply with the same shape.
class AppError(Exception): def __init__(self, status, code, message): self.status, self.code, self.message = status, code, message
try: raise AppError(409, "out_of_stock", "Only 2 left")except AppError as err: print(err.status, err.code)Example 2 · One handler for all errors
from fastapi import FastAPI, Requestfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponsefrom fastapi.testclient import TestClientfrom pydantic import BaseModel
app = FastAPI()
class AppError(Exception): def __init__(self, status, code, message): self.status, self.code, self.message = status, code, message
def body(code, message, details=()): return {"error": {"code": code, "message": message, "details": list(details)}}
@app.exception_handler(AppError)async def on_app_error(request: Request, exc: AppError): return JSONResponse(status_code=exc.status, content=body(exc.code, exc.message))
@app.exception_handler(RequestValidationError)async def on_invalid(request: Request, exc: RequestValidationError): details = [{"field": ".".join(str(p) for p in e["loc"][1:]), "type": e["type"]} for e in exc.errors()] return JSONResponse(status_code=422, content=body("validation_failed", "Invalid request", details))
class Order(BaseModel): item: str qty: int
@app.post("/orders", status_code=201)def create(order: Order): if order.qty > 5: raise AppError(409, "out_of_stock", "Only 5 left") return {"ok": True}
client = TestClient(app)r = client.post("/orders", json={"item": "pen", "qty": 9})print(r.status_code, r.json())r = client.post("/orders", json={"item": "pen", "qty": "many"})print(r.status_code, r.json())409 {'error': {'code': 'out_of_stock', 'message': 'Only 5 left', 'details': []}}
422 {'error': {'code': 'validation_failed', 'message': 'Invalid request', 'details': [{'field': 'qty', 'type': 'int_parsing'}]}}What this code does
- Business code raises
AppError; the handler builds the response. - Validation failures keep the 422 status but now use the same body shape.
The route stays clean: it raises, and the handler builds the response. Never send a stack trace to the client.
Version the API
API version is a numbered edition of the API contract, usually shown in the URL such as /v1/. A new version is needed only when a change would break existing clients.
Clients depend on your field names. When you must change them, publish a new version and keep the old one running for a while. The simplest and most common way is a prefix in the path: /v1/, /v2/.
ROUTES = { "/v1/customers/1": {"name": "Asha Rao"}, "/v2/customers/1": {"first_name": "Asha", "last_name": "Rao"},}print(sorted(ROUTES))Example 3 · Two versions side by side
from fastapi import APIRouter, FastAPI, Responsefrom fastapi.testclient import TestClient
app = FastAPI()v1 = APIRouter(prefix="/v1")v2 = APIRouter(prefix="/v2")
@v1.get("/customers/1")def old(response: Response): response.headers["Deprecation"] = "true" return {"name": "Asha Rao"}
@v2.get("/customers/1")def new(): return {"first_name": "Asha", "last_name": "Rao"}
app.include_router(v1)app.include_router(v2)
client = TestClient(app)r1 = client.get("/v1/customers/1")print(r1.json(), r1.headers["deprecation"])print(client.get("/v2/customers/1").json()){'name': 'Asha Rao'} true
{'first_name': 'Asha', 'last_name': 'Rao'}What this code does
- Old clients still get the old shape, with a warning header.
- The new shape lives on its own prefix, so neither breaks the other.
Old clients keep calling /v1. New clients move to /v2 when they are ready.
Breaking and safe changes
Adding a new optional field or a new endpoint is safe. Removing or renaming a field, changing a type, or making an optional input required breaks clients. Announce the end of an old version early, with a Deprecation header.
old = {"id", "name", "city"}new = {"id", "name", "city", "email"}print("removed:", sorted(old - new))print("added:", sorted(new - old))Example 4 · Is this change breaking?
def compare(old, new): removed = sorted(old - new) added = sorted(new - old) return {"breaking": bool(removed), "removed": removed, "added": added}
v1 = {"id", "name", "city"}print(compare(v1, {"id", "name", "city", "email"}))print(compare(v1, {"id", "full_name", "city"})){'breaking': False, 'removed': [], 'added': ['email']}
{'breaking': True, 'removed': ['name'], 'added': ['full_name']}What this code does
- Adding
emailis safe: old clients ignore it. - Renaming
nameremoves a field, so it is breaking.
Nothing was removed, so this change is safe. A non-empty removed set would mean a breaking change.
Next step
Check your understanding
12 quick questions