HTTPException, custom handlers, and keeping HTTP concerns out of the code that does the work.
Overview
The ordinary case
Error handling is the part of an API that decides what a client sees when something goes wrong. A missing record, a rule that was broken, or a bug in your own code all need a response. If you plan for them, clients get clear messages. If you do not, they get a confusing mix of formats.
FastAPI gives you HTTPException for the ordinary case. You raise it with a status code and a message, and the framework turns it into a JSON response. That covers a lot. As your app grows, you will want more control over the shape of every error and over where in the code each one gets raised.
This article starts with the ordinary case and what the client receives. It then shows how to add headers to an error, which some protocols require. After that it explains how to replace the default error envelope with one that matches the rest of your API, so every failure has the same structure.
A larger idea follows. Your business logic should not know about HTTP. It should raise its own errors, such as a missing item or a conflict, and the edge of your app should translate those into status codes. That keeps the core clean and testable.
The last section covers the catch-all handler for errors nobody predicted, and how to make sure it logs the problem without leaking details to the client.
Good error handling is mostly a matter of deciding things once, in one place. Each section is short and ends with a habit you can apply straight away. You finish knowing where each kind of error should be raised, translated, and logged, and why that split keeps your code easy to change.
raise HTTPException(status.HTTP_404_NOT_FOUND, "Module not found")
It can be raised anywhere in the call stack, not just in the handler. FastAPI turns it into a response with that status and {"detail": ...}.
Two properties are worth noticing. It's an exception. It unwinds. A function five levels deep can refuse the request without each caller checking a return value. And the response shape is steady. So Each client can lean on detail being there.
detail doesn't have to be a string. A dict or list is serialised. This Is one way to return structured errors without a custom handler.
Worth knowing
HTTPException(status, detail) can be raised anywhere in the call stack and becomes {"detail": ...}.
It takes headers=, which some statuses require — WWW-Authenticate on a 401, Retry-After on a 429.
@app.exception_handler(HTTPException) replaces the default envelope for every raise in the app.
Custom exception classes with their own handlers keep HTTP out of the service layer: raise ModuleNotFound, map it to a 404 in one place.
A handler for Exception turns an unhandled error into a clean 500. Log the traceback; never return it.
That base-Exception handler is special: Starlette builds the response and then re-raises so the server still logs the failure. TestClient(app, raise_server_exceptions=False) is how a test inspects the 500.
RequestValidationError has its own handler, separate from HTTPException, so validation failures and raised errors can differ.
Error Handling
HTTPException, custom handlers, and keeping HTTP out of the code that does the work.
HTTPException is the ordinary way
Raise it anywhere in the call stack. FastAPI turns it into a response with your status and detail.
example_01.pyFastAPI
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
DB = {1: {"id": 1, "title": "Vectors"}}
@app.get("/modules/{i}")
def read(i: int):
if i not in DB:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Module not found")
return DB[i]
c = TestClient(app)
print("found :", c.get("/modules/1").json())
r = c.get("/modules/99")
print("missing:", r.status_code, r.json())
print("shape :", list(r.json()), "<- always {'detail': ...}")
Output
It can carry headers
Some statuses are meaningless without one — 401 wants WWW-Authenticate, 429 wants Retry-After.
example_02.pyFastAPI
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
@app.get("/limited")
def limited():
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS,
"Slow down", headers={"Retry-After": "30"})
r = TestClient(app).get("/limited")
print("status :", r.status_code)
print("detail :", r.json()["detail"])
print("retry-after:", r.headers.get("retry-after"))
Output
Your own envelope
Override the handler for HTTPException and every raise in the app returns the shape your clients expect.
example_03.pyFastAPI
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(HTTPException)
async def as_problem(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"status": exc.status_code,
"message": exc.detail,
"path": request.url.path}},
headers=getattr(exc, "headers", None) or {})
@app.get("/modules/{i}")
def read(i: int):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Module not found")
r = TestClient(app).get("/modules/9")
print(r.status_code, r.json())
Output
Domain errors, mapped once
Raise a plain exception from code that knows nothing about HTTP, and translate it at the edge.
example_04.pyFastAPI
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
app = FastAPI()
class ModuleNotFound(Exception):
def __init__(self, module_id):
self.module_id = module_id
class SlugTaken(Exception):
def __init__(self, slug):
self.slug = slug
@app.exception_handler(ModuleNotFound)
async def not_found(request, exc):
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND,
content={"detail": "No module %d" % exc.module_id})
@app.exception_handler(SlugTaken)
async def taken(request, exc):
return JSONResponse(status_code=status.HTTP_409_CONFLICT,
content={"detail": "Slug %r is taken" % exc.slug})
# The service layer: no HTTP anywhere in it.
def get_module(i):
raise ModuleNotFound(i)
def create_module(slug):
raise SlugTaken(slug)
@app.get("/modules/{i}")
def read(i: int):
return get_module(i)
@app.post("/modules")
def create(slug: str):
return create_module(slug)
c = TestClient(app)
print(c.get("/modules/9").status_code, c.get("/modules/9").json())
print(c.post("/modules?slug=vectors").status_code, c.post("/modules?slug=vectors").json())
Output
Catching everything else
A handler for Exception turns an unhandled error into a clean 500. Never return the traceback.
example_05.pyFastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
LOG = []
@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception):
LOG.append("%s %s -> %s: %s" % (request.method, request.url.path,
type(exc).__name__, exc))
return JSONResponse(status_code=500,
content={"detail": "Something went wrong",
"request_id": "req-42"})
@app.get("/boom")
def boom():
return 1 / 0
# A handler for the base Exception is special: Starlette builds the 500
# response from it and then RE-RAISES, so the server still logs the real
# failure. A test client that wants to inspect the 500 asks for that with
# raise_server_exceptions=False; here we just catch it.
try:
r = TestClient(app).get("/boom")
print("client sees:", r.status_code, r.json())
except ZeroDivisionError:
print("re-raised to the server, as Starlette intends")
print()
print("our handler still ran:", LOG[0])
print()
print("The traceback stays in the log. It is an information leak in a body.")
Output
Validation errors keep their own handler
RequestValidationError is separate from HTTPException, so the two can have different shapes.
example_06.pyFastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def invalid(request: Request, exc: RequestValidationError):
fields = {}
for err in exc.errors():
key = ".".join(str(p) for p in err["loc"][1:]) or "_body"
fields.setdefault(key, []).append(err["msg"])
return JSONResponse(status_code=422, content={"invalid": fields})
class ModuleIn(BaseModel):
title: str = Field(min_length=3)
@app.post("/modules")
def create(body: ModuleIn):
raise HTTPException(404, "Not found")
c = TestClient(app)
print("validation:", c.post("/modules", json={"title": "x"}).json())
print("raised :", c.post("/modules", json={"title": "Vectors"}).json())
Output
Headers on an error
HTTPException takes headers=, and a few statuses are incomplete without one.
A 401 should carry WWW-Authenticate showing the scheme. A 429 should carry Retry-After. A 405 should carry Allow.
Clients act on these on its own. Omitting them makes the response strictly correct and in practice unhelpful.
Replacing the envelope
If your API has an set error format, replace the default handler:
each HTTPException in the app now returns your shape. Two things to note. Pass the status through rather than hard-coding one. Forward exc.headers, or the Retry-After you carefully set disappears.
Worth doing once, early, if you're going to do it at all. Retrofitting an envelope after clients exist is a breaking change.
Domain errors, mapped at the edge
This is the pattern that matters most for anything beyond a small app.
Business logic shouldn't import fastapi. A function that looks up a module and can't find one should raise ModuleNotFound, not HTTPException(404). Because that function might also be called from a background job, a CLI or a test, none of which have a request to react to.
The gains compound. The service layer is testable without a client. The HTTP mapping lives in one file where it can be reviewed. Changing 404 to 410 for a case is one edit. And the same service can be exposed over a other transport without rewriting its errors.
The cost is a handler per error class. This Is a few lines each and worth it past a handful of endpoints.
The catch-all
A handler for Exception catches anything you didn't anticipate:
Log the real exception, with a request identifier. So The failure is diagnosable.
Return a thing plain. A traceback in a response body is an data leak. It names files, line numbers, library versions and at times values. And it's the kind that ends up in a screenshot in a public issue.
Include a link id the caller can quote. That turns "it broke" into a log line you can find.
Note that this handler doesn't run in the same way when the app is in debug mode or under some test configs. Here The exception is re-raised so you can see it. That is deliberate and worth knowing when it appears not to work locally.
Validation stays separate
RequestValidationError has its own handler, distinct from HTTPException.
That split is handy: check failures are structured, rote and want a field-oriented shape, while raised errors are single messages. Handling them together forces one envelope onto two other kinds of problem.
There's also ResponseValidationError for when your own handler returns a thing the response model rejects. That one should be loud in logs. It means your code broke its own contract. No caller can do anything about it.
What not to do
Don't return errors instead of raising them. A handler that returns {"error": "..."} with a 200 defeats each client's error handling.
Don't catch and swallow. A bare except Exception: pass around a DB call turns a failure into a wrong answer.
**Don't leak internals in detail.** "Module not found" is right. The SQL that failed isn't.
Don't use 500 for a caller's mistake. If they could have avoided it, it's a 4xx. A 500 means your code failed, and it should page somebody.
A worked shape
For an app of any size, the setup that stays healthy:
Service functions raise domain exceptions and know nothing about HTTP. One module registers a handler per domain exception, mapping each to a status. A handler for HTTPException applies the house envelope. A handler for RequestValidationError shapes check failures. A handler for Exception logs and returns a plain 500 with an id.
Five handlers, written once, and each endpoint after that just raises what it means.
Mistakes people make with Error Handling
Returning errors instead of raising them. A handler returning {"error": ...} with a 200 defeats each client's error handling. Can't be made from four levels down.
Importing fastapi in the service layer. A function that raises HTTPException can only be used from a request - not from a job, a CLI or a test.
Swallowing exceptions. A bare except Exception: pass around a DB call turns a failure into a wrong answer. This Is far worse than an error.
Returning the traceback. It names files, line numbers, library versions and at times values. Log it. Send an id.
**Hard-coding the status in a custom HTTPException handler.** each non-404 then returns the wrong code.
**Dropping exc.headers in that handler.** The Retry-After you carefully attached silently disappears.
500 for a thing the caller did. If a other request would have worked, it's a 4xx.
Where the layers sit
It helps to see the whole setup at once.
Service functions raise domain exceptions - ModuleNotFound, SlugTaken, QuotaExceeded - and import nothing from the web framework.
One mapping module registers a handler per domain exception, each choosing a status. This is the only place that knows a missing module is a 404.
**One handler for HTTPException** applies the house envelope to all raised right.
**One handler for RequestValidationError** shapes check failures. This Want a field-oriented format the others don't.
**One handler for Exception** logs and returns a plain 500 with a link id.
Five handlers written once. After that each endpoint raises what it means and nothing repeats the mapping.
What the client should see
An error response has one job: let the caller decide what to do next. Three things serve that, and all else is decoration.
A status they can branch on. Retry, re-authenticate, fix the request, or give up.
A message a person could act on. "Module not found" is handy. "error" isn't. A stack trace is worse than either.
An identifier they can quote. When they open a ticket saying it failed, an id turns a search through logs into one lookup.
What doesn't belong: inner identifiers, SQL, file paths, library versions, or the values of anything sensitive. Each of those helps somebody attacking you more than it helps the caller.
A shape that covers it:
{"detail": "Module not found", "request_id": "req-8f2c"}
Small, boring, and enough. If your organisation has an set format - RFC 7807 problem details, say - use it, and use it all over rather than on the endpoints somebody remembered.
Errors during a response
One case that surprises people: an exception raised *after* the response has started streaming can't become a clean error. The status line and headers have already been sent.
That is why a StreamingResponse whose gen fails mid-way makes a truncated body rather than a 500. The client sees a link that ended early. This Is the same from a network failure.
The mitigation is to do the work that can fail before you start streaming. To keep gens simple. It's also a reason not to reach for streaming unless the payload truly needs it.
Testing the failures
Error paths are the least-tested part of most uses, and the easiest to test here.
A test that a missing module gives 404, that a copy gives 409. That a malformed body gives 422 with the right loc costs three short functions and covers the branches most likely to be wrong.
Assert on the status and on type or a stable key - not on the message, for the same reason as in the Pydantic track. Prose gets reworded. A suite that fails on wording is a suite people learn to skip.
Errors as part of the contract
The failure modes of an API are part of its interface. Treating them as an afterthought shows.
A caller integrating with your service needs to know. Which errors are permanent and which are worth retrying. Whether a 409 means "try again with other data" or "this already happened, you're done". And whether an error response is stable enough to branch on.
Answering those in docs costs little. responses= puts the shapes in the schema. A sentence per error class says what a caller should do about it. Both are read far more often than they're written.
The option - discovering the error contract by causing failures in production - is what most integrations really do. It's why so many clients end up matching on message strings that later change.
What good error handling feels like
From the outside, an API with good error handling has three properties, none of which is about code.
Failures are predictable: the same mistake always makes the same status and shape. A client can be written once.
They're actionable: the message says what to change. The status says whether changing anything would help.
They're diagnosable: when a thing is truly broken, both sides can refer to the same identifier.
all in this module is in service of those. The handlers, the domain exceptions, the envelope and the logging are methods. The properties are the point. They're what a caller notices.
Error Handling: summary
HTTPException can be raised anywhere in the call stack and turns a steady JSON body. It takes headers. This A few statuses need.
Business logic should raise domain exceptions and import nothing from the framework. One handler per exception class maps them to statuses at the edge. This Keeps the service usable from a job, a CLI or a test.
A handler for Exception turns anything unanticipated into a clean 500 - logged in full, returned as a plain message plus an id. check keeps its own handler. Field-oriented failures want a other shape from single-message ones.
Where to go after Error Handling
Structure comes next: splitting a growing use into routers. That the error handlers, the models and the endpoints each live one spot a newcomer can find them - and so that main.py stays short enough to read in one screen.
Questions people ask about Error Handling
How do I return an error from an endpoint? Raise HTTPException(status_code=404, detail="Item not found"). FastAPI turns it into a JSON response with a detail key.
Can I add headers to an error? Yes. Pass headers={"WWW-Authenticate": "Bearer"} to HTTPException.
How do I handle a custom exception globally? Register a function with @app.exception_handler(MyError) that returns a JSONResponse.
How do I catch unexpected exceptions? Add a handler for Exception, or middleware, that logs the error and returns a generic 500. Never send tracebacks to clients.
Is HTTPException the same as a Python exception I can catch? It is a real exception, so raising it stops the function. Do not wrap it in a bare except that swallows it.
Should domain code raise HTTPException? Better not. Raise domain errors from the business logic and map them to status codes at the edge in handlers.
A final note
The setup given here - domain exceptions raised low, mapped once at the edge - is worth adopting earlier than it feels needed.
Retrofitting it means finding each HTTPException scattered through service code and deciding what each should have been, often while also changing a thing else. Starting with it costs one extra class and one handler on the first error you need.
Check yourself
0 of 4
Answer without scrolling back up.
Where can `HTTPException` be raised?
It is an exception, so it unwinds. A function five levels deep can refuse the request without every caller checking a return value.
Why raise `ModuleNotFound` in a service rather than `HTTPException(404)`?
Business logic that imports fastapi can only be used from a request. Mapping the domain error to a status at the edge keeps the service reusable and testable.
What should an `Exception` handler return to the client?
A traceback names files, versions and sometimes values - an information leak. Log it, and give the caller an id they can quote.
You override the `HTTPException` handler. What is easy to forget?
Hard-coding a status breaks every non-404, and dropping `exc.headers` silently discards things like `Retry-After` and `WWW-Authenticate`.
Cheat sheet
Error Handling
Raise an HTTPException, register a custom exception handler, and keep HTTP status codes out of the business logic that does the real work.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.