FastAPI · lesson 6 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6FastAPI Error Handling: HTTPException and Custom Handlers
Raise HTTPException with details and headers, map your own exceptions to responses, reshape FastAPI's 422, catch crashes, and test errors with TestClient.
By SkillByExample · Published
Three Kinds of Errors
By now you’ve met three kinds of failure:
- The client sent something invalid. FastAPI catches it before your code runs and answers 422.
- Your code decides the request can’t be served: the product doesn’t exist, the stock ran out. You raise
HTTPException. - Your code crashes with an exception nobody planned for. The client gets a bare 500.
This lesson takes control of all three. Outputs come from FastAPI 0.143.0, Starlette 1.7.0 and Pydantic 2.14.1 on Python 3.12. Run each file with uvicorn <file>:app.
HTTPException Carries a Status, a Detail and Headers
Save as raise_http.py:
from fastapi import FastAPI, HTTPException
app = FastAPI()
STOCK = {"kettle": 3, "toaster": 0}
@app.post("/orders/{product}")
def order(product: str, quantity: int = 1):
if product not in STOCK:
raise HTTPException(status_code=404, detail=f"No product called {product!r}")
if STOCK[product] < quantity:
raise HTTPException(
status_code=409,
detail={"error": "out_of_stock", "available": STOCK[product]},
headers={"Retry-After": "3600"},
)
STOCK[product] -= quantity
return {"ordered": quantity, "left": STOCK[product]}
$ curl -i -X POST http://127.0.0.1:8000/orders/kettle
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 07:01:04 GMT
server: uvicorn
content-length: 22
content-type: application/json
{"ordered":1,"left":2}
$ curl -i -X POST http://127.0.0.1:8000/orders/teapot
HTTP/1.1 404 Not Found
date: Tue, 13 Oct 2026 07:01:04 GMT
server: uvicorn
content-length: 39
content-type: application/json
{"detail":"No product called 'teapot'"}
$ curl -i -X POST http://127.0.0.1:8000/orders/toaster
HTTP/1.1 409 Conflict
date: Tue, 13 Oct 2026 07:01:04 GMT
server: uvicorn
retry-after: 3600
content-length: 49
content-type: application/json
{"detail":{"error":"out_of_stock","available":0}}
raise works like a return for errors: the function stops at once, so STOCK[product] -= quantity never runs for the toaster. detail doesn’t have to be a string. A dict gives clients a machine-readable error code to branch on, and FastAPI nests it under "detail".
409 Conflict means the request was valid but clashes with the current state of the resource; here, the stock. The Retry-After header tells the client how many seconds to wait before trying again. Choosing the right code matters because clients act on it: 404 says “don’t retry”, 409 with Retry-After says “try later”.
Your Own Exceptions, Mapped to Responses
HTTPException ties your business logic to HTTP. Often the code that detects a problem shouldn’t know about status codes at all. Raise a plain Python exception, and teach FastAPI how to turn it into a response. Save as custom.py:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
class InsufficientFunds(Exception):
def __init__(self, balance: int, needed: int):
self.balance = balance
self.needed = needed
@app.exception_handler(InsufficientFunds)
def insufficient_funds_handler(request: Request, exc: InsufficientFunds):
return JSONResponse(
status_code=402,
content={"error": "insufficient_funds", "short_by": exc.needed - exc.balance},
)
@app.post("/pay")
def pay(amount: int):
balance = 50
if amount > balance:
raise InsufficientFunds(balance=balance, needed=amount)
return {"paid": amount}
$ curl -s -X POST 'http://127.0.0.1:8000/pay?amount=20'
{"paid":20}
$ curl -i -X POST 'http://127.0.0.1:8000/pay?amount=80'
HTTP/1.1 402 Payment Required
date: Tue, 13 Oct 2026 07:01:05 GMT
server: uvicorn
content-length: 44
content-type: application/json
{"error":"insufficient_funds","short_by":30}
@app.exception_handler(InsufficientFunds) registers a function that FastAPI calls whenever that exception escapes a route. It receives the request and the exception object, so it can use the exception’s attributes, here to compute short_by. Every route that raises InsufficientFunds now gets the same response, and the response shape is defined in one place. The body is exactly your content, with no "detail" wrapper, because you built the whole response.
Reshaping the 422
FastAPI’s validation errors are detailed but verbose. If your API has its own error format, register a handler for RequestValidationError, the exception FastAPI raises when a request fails validation. Save as reshape.py:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
@app.exception_handler(RequestValidationError)
def validation_handler(request: Request, exc: RequestValidationError):
errors = [
{"field": ".".join(str(p) for p in e["loc"][1:]), "message": e["msg"]}
for e in exc.errors()
]
return JSONResponse(status_code=400, content={"errors": errors})
class Signup(BaseModel):
email: str
age: int
@app.post("/signup")
def signup(data: Signup):
return data
$ curl -i http://127.0.0.1:8000/signup -H 'content-type: application/json' -d '{"age": "old"}'
HTTP/1.1 400 Bad Request
date: Tue, 13 Oct 2026 07:01:06 GMT
server: uvicorn
content-length: 155
content-type: application/json
{"errors":[{"field":"email","message":"Field required"},{"field":"age","message":"Input should be a valid integer, unable to parse string as an integer"}]}
$ curl -s 'http://127.0.0.1:8000/signup' -X POST
{"errors":[{"field":"","message":"Field required"}]}
exc.errors() is the list you saw in earlier lessons’ 422 bodies. The handler drops the first loc element ("body") and joins the rest into a field name. It also switches the status to 400 Bad Request, the generic “your request is wrong” code, which some API style guides prefer. That’s a choice, not a fix: /docs still advertises 422 for these routes.
The second request sent no body. Its loc was just ["body"], so nothing was left after dropping the first element and the field came out empty. Practice task 3 fixes it.
Handlers for HTTPException, and the 404 They Miss
You can also replace the response for every HTTPException. Save as notfound.py:
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(HTTPException)
def http_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail, "path": request.url.path},
)
@app.get("/books/{book_id}")
def get_book(book_id: int):
raise HTTPException(status_code=404, detail="Book not found")
$ curl -s http://127.0.0.1:8000/books/1
{"error":"Book not found","path":"/books/1"}
$ curl -s http://127.0.0.1:8000/authors/1
{"detail":"Not Found"}
$ curl -s -X DELETE http://127.0.0.1:8000/books/1
{"detail":"Method Not Allowed"}
Your handler reshaped the 404 your route raised, but not the 404 for an unknown path or the 405 for a wrong method. Those come from the router before any route runs, and the router raises Starlette’s HTTPException. FastAPI’s HTTPException is a subclass of Starlette’s, so a handler for the subclass never sees the parent. Practice task 1 registers the handler on the right class.
A Safety Net for Crashes
A handler for Exception catches anything nothing else did. Save as catchall.py:
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(Exception)
def unhandled(request: Request, exc: Exception):
error_id = uuid.uuid4().hex[:8]
print(f"error {error_id}: {exc!r}")
return JSONResponse(
status_code=500,
content={"error": "internal", "error_id": error_id},
)
@app.get("/ratio")
def ratio(a: int, b: int):
return {"ratio": a / b}
$ curl -s 'http://127.0.0.1:8000/ratio?a=1&b=4'
{"ratio":0.25}
$ curl -i 'http://127.0.0.1:8000/ratio?a=1&b=0'
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 07:01:09 GMT
server: uvicorn
content-length: 42
content-type: application/json
{"error":"internal","error_id":"6d6c6630"}
The client now gets JSON instead of plain text, plus an error_id a user can quote in a bug report. The server log shows the same id, and the traceback is still logged. Here is the start and end of it, with the framework frames in between trimmed:
error 6d6c6630: ZeroDivisionError('division by zero')
INFO: 127.0.0.1:49967 - "GET /ratio?a=1&b=0 HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "C:\tmp\fapi-b1\venv\Lib\site-packages\uvicorn\protocols\http\h11_impl.py", line 411, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\tmp\fapi-b1\l6\catchall.py", line 21, in ratio
return {"ratio": a / b}
~~^~~
ZeroDivisionError: division by zero
An Exception handler is special. Starlette runs it in its outermost error middleware, sends your response, and then re-raises the exception so the server still logs it. That’s deliberate: a crash should never disappear silently. The traceback points at line 21 of catchall.py, the division.
Testing Error Responses with TestClient
Errors are part of your API’s contract, so test them. FastAPI’s TestClient calls the app directly, with no server or network. It needs an HTTP client library underneath: pip install pytest httpx2. Plain httpx also works, but Starlette 1.7 prints a deprecation warning recommending httpx2. Save as test_errors.py next to the two apps:
from fastapi.testclient import TestClient
from catchall import app as catchall_app
from raise_http import app
client = TestClient(app)
def test_unknown_product_is_404():
response = client.post("/orders/teapot")
assert response.status_code == 404
assert response.json() == {"detail": "No product called 'teapot'"}
def test_out_of_stock_is_409_with_retry_after():
response = client.post("/orders/toaster")
assert response.status_code == 409
assert response.headers["retry-after"] == "3600"
def test_division_by_zero_is_500():
response = TestClient(catchall_app).get("/ratio", params={"a": 1, "b": 0})
assert response.status_code == 500
The beginning and end of the run, with library frames trimmed from the middle:
$ pytest test_errors.py
============================= test session starts =============================
platform win32 -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\tmp\fapi-b1\l6
plugins: anyio-4.15.1
collected 3 items
test_errors.py ..F [100%]
================================== FAILURES ===================================
________________________ test_division_by_zero_is_500 _________________________
def test_division_by_zero_is_500():
> response = TestClient(catchall_app).get("/ratio", params={"a": 1, "b": 0})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_errors.py:22:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
a = 1, b = 0
@app.get("/ratio")
def ratio(a: int, b: int):
> return {"ratio": a / b}
^^^^^
E ZeroDivisionError: division by zero
catchall.py:21: ZeroDivisionError
---------------------------- Captured stdout call -----------------------------
error a17a9e5c: ZeroDivisionError('division by zero')
=========================== short test summary info ===========================
FAILED test_errors.py::test_division_by_zero_is_500 - ZeroDivisionError: divi...
========================= 1 failed, 2 passed in 1.23s =========================
The 404 and 409 tests pass: response.json() parses the body and response.headers is case-insensitive like HTTP itself. The third test never reaches its assert. The captured stdout proves the handler ran and built its 500, yet the test got the original ZeroDivisionError. TestClient re-raises server exceptions by default, the same way the server re-raised it for logging, so a crashing route fails your test loudly. Practice task 2 asserts on the 500 instead.
Practice
- Make
notfound.py’s handler also reshape the router’s 404 for/authors/1and the 405 forDELETE /books/1.
Solution
Register the handler on Starlette’s HTTPException, the parent class:
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
app = FastAPI()
@app.exception_handler(StarletteHTTPException)
def http_handler(request: Request, exc: StarletteHTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail, "path": request.url.path},
)
@app.get("/books/{book_id}")
def get_book(book_id: int):
raise HTTPException(status_code=404, detail="Book not found")
$ curl -s http://127.0.0.1:8000/books/1
{"error":"Book not found","path":"/books/1"}
$ curl -s http://127.0.0.1:8000/authors/1
{"error":"Not Found","path":"/authors/1"}
$ curl -s -X DELETE http://127.0.0.1:8000/books/1
{"error":"Method Not Allowed","path":"/books/1"}
Routes still raise FastAPI’s HTTPException; a handler for the parent class catches the subclass too.
- Fix
test_division_by_zero_is_500so it checks the JSON 500 response instead of failing with the exception.
Solution
def test_division_by_zero_is_500():
crash_client = TestClient(catchall_app, raise_server_exceptions=False)
response = crash_client.get("/ratio", params={"a": 1, "b": 0})
assert response.status_code == 500
assert response.json()["error"] == "internal"
$ pytest -v test_errors.py
============================= test session starts =============================
platform win32 -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0 -- C:\tmp\fapi-b1\venv2\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\tmp\fapi-b1\l6
plugins: anyio-4.15.1
collecting ... collected 3 items
test_errors.py::test_unknown_product_is_404 PASSED [ 33%]
test_errors.py::test_out_of_stock_is_409_with_retry_after PASSED [ 66%]
test_errors.py::test_division_by_zero_is_500 PASSED [100%]
============================== 3 passed in 0.26s ==============================
Keep the default client for most tests so real crashes still fail loudly, and use a separate raise_server_exceptions=False client only where the 500 itself is what you’re testing.
- In
reshape.py, a request with no body reports"field": "". Make it say"body"instead.
Solution
Change the dict inside the list comprehension:
{"field": ".".join(str(p) for p in e["loc"][1:]) or "body", "message": e["msg"]}
$ curl -s -X POST http://127.0.0.1:8000/signup
{"errors":[{"field":"body","message":"Field required"}]}
$ curl -s http://127.0.0.1:8000/signup -H 'content-type: application/json' -d '{"email": "a@b.c"}'
{"errors":[{"field":"age","message":"Field required"}]}
An empty string is falsy, so or "body" only kicks in when there was nothing after "body" in loc.