Error Handling
22 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Things go wrong in every API: a record is missing, input is bad, a bug appears. Good error handling gives the client a clear code and message, keeps the routes short, and keeps secrets inside the server.
HTTPException stops a request with an error
HTTPException is an exception from FastAPI. When raised in a route or dependency, FastAPI stops the request and sends the status code, detail and headers you gave.
Raise HTTPException anywhere inside a route or a dependency, and FastAPI stops and sends that status code. The detail is the message. It can be text or a dict. You can also add headers, such as WWW-Authenticate for a 401.
from fastapi import HTTPException
@app.get("/orders/{order_id}")def read_order(order_id: int): if order_id not in orders: raise HTTPException(status_code=404, detail="Order not found") return orders[order_id]Example 1 · HTTPException with a dict and headers
from fastapi import FastAPIfrom fastapi.testclient import TestClient
app = FastAPI()from fastapi import HTTPException
@app.get("/secure")def secure(token: str = ""): if token != "abc": raise HTTPException( status_code=401, detail={"reason": "bad token"}, headers={"WWW-Authenticate": "Bearer"}, ) return {"ok": True}
c = TestClient(app)r = c.get("/secure")print(r.status_code, r.json(), r.headers["www-authenticate"])print(c.get("/secure?token=abc").json())401 {'detail': {'reason': 'bad token'}} Bearer
{'ok': True}What this code does
detailmay be a dict, and it comes back unchanged insidedetail.- Headers given in the exception are added to the response.
The default body is {"detail": "Order not found"}. Raising is better than returning an error dict, because it also leaves the route at once.
Your own exceptions and handlers
Services should not know about HTTP, so they raise plain exceptions such as NotFound or Conflict. The app registers one handler per exception class. The handler turns the exception into a response. The routes stay free of try and except.
class NotFound(Exception): pass
@app.exception_handler(NotFound)def handle_not_found(request, exc): return JSONResponse(status_code=404, content={"message": str(exc)})Example 2 · Domain errors become HTTP answers in one place
from fastapi import FastAPI, Requestfrom fastapi.responses import JSONResponsefrom fastapi.testclient import TestClient
class AppError(Exception): status_code = 400 code = "app_error"
def __init__(self, message): self.message = message
class NotFound(AppError): status_code = 404 code = "not_found"
class Conflict(AppError): status_code = 409 code = "conflict"
app = FastAPI()
@app.exception_handler(AppError)def handle_app_error(request: Request, exc: AppError): return JSONResponse(status_code=exc.status_code, content={"error": {"code": exc.code, "message": exc.message}})
products = {1: "Keyboard"}
@app.get("/products/{product_id}")def read_product(product_id: int): if product_id not in products: raise NotFound(f"Product {product_id} does not exist") return {"id": product_id, "name": products[product_id]}
@app.post("/products/{name}")def add_product(name: str): if name in products.values(): raise Conflict(f"{name} already exists") products[len(products) + 1] = name return {"added": name}
c = TestClient(app)print(c.get("/products/1").json())r = c.get("/products/7")print(r.status_code, r.json())r = c.post("/products/Keyboard")print(r.status_code, r.json()){'id': 1, 'name': 'Keyboard'}
404 {'error': {'code': 'not_found', 'message': 'Product 7 does not exist'}}
409 {'error': {'code': 'conflict', 'message': 'Keyboard already exists'}}What this code does
- The routes raise short, named errors with no status codes.
- Subclasses carry their own
status_codeandcode, so one handler serves them all.
A handler catches the exception from any route. One place decides how the error looks, so every route answers the same way.
One error body for the whole API
Clients are easier to write when every error has the same shape. Choose one, for example {"error": {"code": ..., "message": ...}}. Use it in your own handlers. Replace the default handlers for HTTPException and RequestValidationError so even FastAPI errors use it.
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)def handle_validation(request, exc): fields = [".".join(map(str, e["loc"][1:])) for e in exc.errors()] return JSONResponse(status_code=422, content={"error": {"code": "invalid_request", "message": "Invalid fields", "fields": fields}})Example 3 · Validation and HTTP errors use the same body
from fastapi import FastAPI, Requestfrom fastapi.responses import JSONResponsefrom fastapi.testclient import TestClient
class AppError(Exception): status_code = 400 code = "app_error"
def __init__(self, message): self.message = message
class NotFound(AppError): status_code = 404 code = "not_found"
class Conflict(AppError): status_code = 409 code = "conflict"
app = FastAPI()
@app.exception_handler(AppError)def handle_app_error(request: Request, exc: AppError): return JSONResponse(status_code=exc.status_code, content={"error": {"code": exc.code, "message": exc.message}})from fastapi import HTTPExceptionfrom fastapi.exceptions import RequestValidationErrorfrom pydantic import BaseModel, Fieldfrom starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(RequestValidationError)def handle_validation(request: Request, exc: RequestValidationError): fields = [".".join(str(p) for p in e["loc"][1:]) for e in exc.errors()] body = {"error": {"code": "invalid_request", "message": "Invalid fields", "fields": fields}} return JSONResponse(status_code=422, content=body)
@app.exception_handler(StarletteHTTPException)def handle_http(request: Request, exc: StarletteHTTPException): return JSONResponse(status_code=exc.status_code, content={"error": {"code": "http_error", "message": str(exc.detail)}})
class OrderIn(BaseModel): item: str qty: int = Field(ge=1)
@app.post("/orders")def create_order(order: OrderIn): return order
@app.get("/orders/{order_id}")def read_order(order_id: int): raise HTTPException(status_code=404, detail="Order not found")
c = TestClient(app)print(c.post("/orders", json={"item": "Pen", "qty": 0}).json())print(c.get("/orders/3").json())print(c.get("/nowhere").json()){'error': {'code': 'invalid_request', 'message': 'Invalid fields', 'fields': ['qty']}}
{'error': {'code': 'http_error', 'message': 'Order not found'}}
{'error': {'code': 'http_error', 'message': 'Not Found'}}What this code does
- The handler for
StarletteHTTPExceptionalso catches errors FastAPI raises itself, such as the 404 for an unknown path. - All three answers now share the
errorkey.
A client now needs one parser for all errors. The code is a stable word that code can check; the message is for people.
Unexpected errors: be quiet outside, loud inside
A bug raises an exception nobody planned for. The client should get a plain 500 with no stack trace or SQL, because those help attackers. Log the details inside the server instead. A handler for Exception does both.
@app.exception_handler(Exception)def handle_unexpected(request, exc): logger.exception("unexpected error") return JSONResponse(status_code=500, content={"error": {"code": "internal_error", "message": "Something went wrong"}})Example 4 · A catch-all for unexpected errors
from fastapi import FastAPIfrom fastapi.testclient import TestClient
app = FastAPI()import loggingfrom fastapi import Requestfrom fastapi.responses import JSONResponse
records = []
class Collect(logging.Handler): def emit(self, record): records.append(record.getMessage())
logger = logging.getLogger("shop")logger.addHandler(Collect())logger.propagate = False
@app.exception_handler(Exception)def handle_unexpected(request: Request, exc: Exception): logger.error("unexpected error: %s", exc) return JSONResponse(status_code=500, content={"error": {"code": "internal_error", "message": "Something went wrong"}})
@app.get("/divide")def divide(a: int, b: int): return {"result": a // b}
c = TestClient(app, raise_server_exceptions=False)print(c.get("/divide?a=8&b=2").json())r = c.get("/divide?a=8&b=0")print(r.status_code, r.json())print(records){'result': 4}
500 {'error': {'code': 'internal_error', 'message': 'Something went wrong'}}
['unexpected error: integer division or modulo by zero']What this code does
- The client sees a short message; the log holds the real reason.
raise_server_exceptions=Falselets the test client return the 500 answer instead of raising.
Example 5 · Checking the error shape for every route
from fastapi import FastAPI, Requestfrom fastapi.responses import JSONResponsefrom fastapi.testclient import TestClient
class AppError(Exception): status_code = 400 code = "app_error"
def __init__(self, message): self.message = message
class NotFound(AppError): status_code = 404 code = "not_found"
class Conflict(AppError): status_code = 409 code = "conflict"
app = FastAPI()
@app.exception_handler(AppError)def handle_app_error(request: Request, exc: AppError): return JSONResponse(status_code=exc.status_code, content={"error": {"code": exc.code, "message": exc.message}})
@app.get("/a")def a(): raise NotFound("a is missing")
@app.get("/b")def b(): raise Conflict("b exists")
@app.get("/c")def c_route(): raise AppError("generic problem")
client = TestClient(app)for path in ("/a", "/b", "/c"): r = client.get(path) error = r.json()["error"] print(path, r.status_code, error["code"], sorted(error))/a 404 not_found ['code', 'message'] /b 409 conflict ['code', 'message'] /c 400 app_error ['code', 'message']
What this code does
- A loop over the routes checks that every error has the same keys.
- The base class gives 400 to any error that has no special class.
The real error goes to the log. The client sees only a short message and can quote the code when asking for help.
Next step
Check your understanding
12 quick questions