Skip to main content
FastAPI · lesson 6 of 19
FastAPI beginner Lesson 6 of 19

FastAPI 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

fastapi python error-handling exceptions testing

Three Kinds of Errors

By now you’ve met three kinds of failure:

  1. The client sent something invalid. FastAPI catches it before your code runs and answers 422.
  2. Your code decides the request can’t be served: the product doesn’t exist, the stock ran out. You raise HTTPException.
  3. 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

  1. Make notfound.py’s handler also reshape the router’s 404 for /authors/1 and the 405 for DELETE /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.

  1. Fix test_division_by_zero_is_500 so 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.

  1. 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.

Frequently Asked Questions

How do I return an error response in FastAPI?
Raise HTTPException(status_code=..., detail=...) from your route. FastAPI stops the function and sends that status code with the body {'detail': ...}. detail can be a string or any JSON-compatible value such as a dict, and the headers argument adds response headers such as Retry-After.
How do I write a custom exception handler in FastAPI?
Define your own exception class, then register a function with @app.exception_handler(YourError) that takes the request and the exception and returns a response, usually a JSONResponse with a status code and body. Any route that raises YourError then gets that response, so business code doesn't need to know about HTTP.
How do I change FastAPI's 422 validation error format?
Register a handler for fastapi.exceptions.RequestValidationError. exc.errors() returns the same list FastAPI would have sent, with type, loc and msg for each problem, and your handler can return it in any shape and with any status code, for example 400 with a simpler list of field and message pairs. The /docs page still documents the default 422 unless you change the route's responses.
Why doesn't my FastAPI HTTPException handler catch 404 for unknown routes?
The 404 for an unknown path and the 405 for a wrong method are raised by Starlette's router as starlette.exceptions.HTTPException, the parent class of FastAPI's HTTPException. A handler registered for fastapi.HTTPException only sees exceptions raised as that subclass. Register the handler for starlette.exceptions.HTTPException to catch both.
Why does TestClient raise an exception instead of returning a 500 response?
By default TestClient re-raises any unhandled server exception inside your test, even when an Exception handler has already produced a 500 response, so the test fails with the original traceback. That's usually what you want while debugging. To assert on the 500 response itself, create the client with TestClient(app, raise_server_exceptions=False).