Errors and HTTPException
Stop a request with HTTPException - from an endpoint or any helper - add headers or a dict detail, raise your own business errors, and write exception handlers so every error in the API has one format. Tested before and after the handlers, including a real bug that gives 500.
What you will be able to do
- Stop a request with HTTPException from an endpoint or a helper function
- Send extra headers and dict details with an error
- Create your own exception class for business errors
- Write exception handlers that give every error the same format
- Change the format of 422 validation errors
- Understand what happens with a real bug, and what the client should see
The idea, in plain English
Lesson 3.1 used HTTPException to return 404 and 409. In this lesson we look closer. HTTPException is a Python exception: when you raise it, the function stops at once, and FastAPI turns it into a response with that status code. It works from any function the endpoint calls - so a helper like get_balance() can stop the whole request.
In a real API, errors come from many places: your HTTPExceptions, FastAPI’s own 404, 405 and 422, your own business errors, and bugs. By default they look different. A frontend developer wants one format for all of them, so one piece of code can show any error.
We build a small bank API, call it once with FastAPI’s defaults, then add exception handlers and call it again. FastAPI 0.143.0.
Worked example: A small bank API: unknown accounts, a login check, hidden limits, not enough money and a real bug - first with FastAPI’s default errors, then with one format for all.
1 - A helper raises HTTPException
get_balance("Z9") raises HTTPException(404). withdraw() and balance() stop too - no extra if-checks needed.
An exception travels up until a handler turns it into a response. Real results from our bank API.
Words you will see in this lesson
A few words about errors.
ExceptionA Python error object. raise starts it; it travels up until something catches it.HTTPExceptionFastAPI’s exception that becomes a response with a status code and detail.detailThe error message in the response. A string, or a dict or list.Exception handlerA function that turns one kind of exception into a response.RequestValidationErrorThe exception behind every 422.TracebackThe list of lines Python was running when the error happened - for developers, not for clients.An everyday example: a help desk
In a big office, problems can start anywhere - in accounts, in the warehouse, in IT. But every customer gets the answer from the same help desk, on the same form: an error code and a short message. The help desk knows how to word each kind of problem. If a problem arrives that nobody planned for, the desk says "Sorry, something went wrong on our side" - and sends the details to the engineers, not to the customer.
Exception handlers are that help desk. Errors can start in any function; the handlers turn them into one kind of answer.
Example 1 - a bank API that raises errors
get_balance is a helper, not an endpoint. When the account does not exist, it raises HTTPException(404). Both balance() and withdraw() call it - and both stop with 404, without their own check. This is how you keep endpoints short.
secret() adds headers=: a 401 should tell the client how to log in, with the WWW-Authenticate header (Module 6). limits() uses a dict as detail - useful when the client needs a machine-readable code next to the message.
NotEnoughMoney is our own exception class. It is not about HTTP at all - it is about our business, and it carries the numbers we need for a good message. withdraw() raises it. crash() has a real bug.
from fastapi import FastAPI, HTTPException
app = FastAPI()
ACCOUNTS = {"A1": 500, "B2": 20}
class NotEnoughMoney(Exception): # our own error, about our business
def __init__(self, account: str, balance: int, wanted: int):
self.account, self.balance, self.wanted = account, balance, wanted
def get_balance(account: str) -> int: # a helper can raise HTTPException too
if account not in ACCOUNTS:
raise HTTPException(status_code=404, detail=f"Account {account} not found")
return ACCOUNTS[account]
@app.get("/accounts/{account}")
def balance(account: str):
return {"account": account, "balance": get_balance(account)}
@app.get("/secret")
def secret():
raise HTTPException(status_code=401, detail="Login needed",
headers={"WWW-Authenticate": "Bearer"}) # extra headers for the client
@app.get("/accounts/{account}/limits")
def limits(account: str):
raise HTTPException(status_code=403,
detail={"code": "LIMITS_HIDDEN", "message": "Only managers can see limits"}) # detail can be a dict
@app.post("/withdraw/{account}/{amount}")
def withdraw(account: str, amount: int):
current = get_balance(account)
if amount > current:
raise NotEnoughMoney(account, current, amount)
ACCOUNTS[account] = current - amount
return {"account": account, "balance": ACCOUNTS[account]}
@app.get("/crash")
def crash():
return 1 / 0 # a bug: nobody handles ZeroDivisionErrorBefore: FastAPI’s default errors
We called every endpoint with TestClient(app, raise_server_exceptions=False), so a crash shows as a 500 response instead of stopping the test.
HTTPException worked everywhere: the helper’s 404, the 401 with its header, the 403 with a dict detail. But look at the shapes: detail is sometimes a string, sometimes a dict, and for 422 a list. And NotEnoughMoney became 500 Internal Server Error - FastAPI does not know our class, so it treats it like a bug. The client cannot tell "not enough money" from a crash.
good -> 200 {"account":"A1","balance":500}
unknown account -> 404 {"detail":"Account Z9 not found"}
needs login -> 401 {"detail":"Login needed"} WWW-Authenticate=Bearer
dict detail -> 403 {"detail":{"code":"LIMITS_HIDDEN","message":"Only managers can see limits"}}
too much -> 500 Internal Server Error
bad amount -> 422 {"detail":[{"type":"int_parsing","loc":["path","amount"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"lots"}
unknown path -> 404 {"detail":"Not Found"}
bug -> 500 Internal Server ErrorExample 2 - exception handlers: one format
An exception handler is a function with @app.exception_handler(SomeException) above it. It receives the request and the exception, and returns a response. FastAPI calls it whenever that exception is raised - in any endpoint or helper.
not_enough_money turns our business error into 400 with a clear code and message, using the numbers stored in the exception.
http_error handles every HTTPException. Note the import: StarletteHTTPException, the class FastAPI’s HTTPException is built on. FastAPI’s own 404 for unknown paths and 405 for wrong methods are raised as the Starlette class, so handling it covers them too. It keeps the status code and the headers (exc.headers), and keeps a dict detail as it is.
validation_error handles RequestValidationError - every 422. exc.errors() is the list you know from Module 2; we turn each item into a short {"field", "message"}.
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from errors import NotEnoughMoney, app
@app.exception_handler(NotEnoughMoney)
def not_enough_money(request: Request, exc: NotEnoughMoney):
return JSONResponse(status_code=400, content={"error": {
"code": "NOT_ENOUGH_MONEY",
"message": f"Balance is {exc.balance}, you asked for {exc.wanted}",
}})
@app.exception_handler(StarletteHTTPException) # every HTTPException - also 404 for unknown paths
def http_error(request: Request, exc: StarletteHTTPException):
detail = exc.detail
if isinstance(detail, dict):
error = detail
else:
error = {"code": f"HTTP_{exc.status_code}", "message": detail}
return JSONResponse(status_code=exc.status_code, content={"error": error}, headers=exc.headers)
@app.exception_handler(RequestValidationError) # every 422
def validation_error(request: Request, exc: RequestValidationError):
fields = [{"field": ".".join(str(x) for x in e["loc"]), "message": e["msg"]} for e in exc.errors()]
return JSONResponse(status_code=422, content={"error": {
"code": "VALIDATION_FAILED", "message": "Some fields are wrong", "fields": fields}})After: every error looks the same
Now every error is {"error": {"code": ..., "message": ...}}. "too much" is a clear 400 NOT_ENOUGH_MONEY instead of a 500. The 401 kept its WWW-Authenticate header, because our handler passed exc.headers on. The unknown path, which our code never touched, has the same format. A frontend can read error.message for any failure.
Only "bug" is still a plain 500. Nothing handles ZeroDivisionError, and that is on purpose - see the next section.
good -> 200 {"account":"A1","balance":500}
unknown account -> 404 {"error":{"code":"HTTP_404","message":"Account Z9 not found"}}
needs login -> 401 {"error":{"code":"HTTP_401","message":"Login needed"}} WWW-Authenticate=Bearer
dict detail -> 403 {"error":{"code":"LIMITS_HIDDEN","message":"Only managers can see limits"}}
too much -> 400 {"error":{"code":"NOT_ENOUGH_MONEY","message":"Balance is 20, you asked for 100"}}
bad amount -> 422 {"error":{"code":"VALIDATION_FAILED","message":"Some fields are wrong","fields":[{"field":"path.amount","message":"Input should be a valid integer, un...
unknown path -> 404 {"error":{"code":"HTTP_404","message":"Not Found"}}
bug -> 500 Internal Server ErrorWhich HTTPException to handle?
We tried a handler for FastAPI’s HTTPException instead of Starlette’s. Our own raised 404 got the new format - but the unknown path still answered {"detail":"Not Found"}. FastAPI’s HTTPException is a child of Starlette’s; a handler for the child does not catch errors raised as the parent. Register the handler for StarletteHTTPException, and raise fastapi.HTTPException in your code as usual.
fastapi.HTTPException handler: raised 404 -> {"error":"No x"} | unknown path -> {"detail":"Not Found"}Real bugs: 500, and the traceback in the log
We ran the app with uvicorn and called /crash. The client got only "500 Internal Server Error" - no file names, no code, no secrets. The server log got the whole story: "Exception in ASGI application", the traceback, and ZeroDivisionError. That split is exactly right: the client cannot fix a bug, the developer can.
In tests, TestClient raises the error by default, so a bug makes the test fail loudly. That is why our run used raise_server_exceptions=False - to see the 500 response instead.
INFO: 127.0.0.1:51678 - "GET /crash HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
INFO: 127.0.0.1:51680 - "GET /accounts/Z9 HTTP/1.1" 404 Not Foundin tests the bug is raised: ZeroDivisionError division by zeroA catch-all for bugs (optional)
If your frontend wants even the 500 in your format, add a handler for Exception - the parent of almost all errors. It only changes what the client sees: we checked with uvicorn, and the traceback was still written to the log.
Never put the exception text in the response. str(exc) can contain file paths, SQL or secrets. Send a fixed message.
@app.exception_handler(Exception)
def any_error(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={"error": {
"code": "INTERNAL", "message": "Something went wrong on our side"}})client: 500 {"error":{"code":"INTERNAL","message":"Something went wrong on our side"}}
uvicorn log:
ERROR: Exception in ASGI application
Traceback (most recent call last):
...
ZeroDivisionError: division by zeroWatch out: Inside a handler, logger.exception("...") printed "NoneType: None" instead of a traceback, because the handler runs after the except block has ended. If you log in a handler, pass the exception: logger.error("...", exc_info=exc).
Errors at a glance
Stop with an errorFrom any function.
raise HTTPException(status_code=404, detail="Not found")
Extra headersSent with the error.
HTTPException(401, "Login needed", headers={"WWW-Authenticate": "Bearer"})Own errorFor business rules.
class NotEnoughMoney(Exception): ...
HandlerException -> response.
@app.exception_handler(NotEnoughMoney) def h(request, exc): return JSONResponse(...)
All HTTP errorsIncludes unknown paths.
@app.exception_handler(StarletteHTTPException)
All 422sReshape validation errors.
@app.exception_handler(RequestValidationError)
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Call GET /withdraw/A1/10 with the handlers on. Which code and format do you get?”
“Add AccountFrozen(Exception) for account "B2" and a handler that answers 423 Locked.”
“Add an endpoint with a Pydantic body and send a wrong body. What does "field" look like now?”
“Run the app with uvicorn, open /crash in the browser, and find the traceback in the terminal.”
What usually goes wrong
A returned dict is a 200. And a helper cannot stop the endpoint by returning.
✗ return {"detail": "Account not found"}✓ raise HTTPException(status_code=404, detail="Account not found")Unknown paths still had the old format. Handle StarletteHTTPException.
A handler that builds a new response loses headers like WWW-Authenticate unless it passes headers=exc.headers.
Internal messages can leak paths, SQL or secrets. Send a fixed message; the log has the details.
NotEnoughMoney became 500, so the client could not tell it from a crash.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Add POST /transfer with a body {from_account, to_account, amount}. Use get_balance for both accounts, raise NotEnoughMoney when needed, and raise a new SameAccount exception (handled as 400, code SAME_ACCOUNT) when both accounts are the same. Test every error and check that all of them have the {"error": {...}} format.
Show hintHide hint
Reuse the helper - it raises 404 for you. Remember the handler for SameAccount.
Key points
- raise HTTPException stops the request at once - also from helper functions.
- HTTPException can carry headers= and a dict or list detail.
- Own exception classes describe business errors; a handler turns them into responses.
- Handle StarletteHTTPException to cover FastAPI’s own 404 and 405 too.
- Handle RequestValidationError to reshape every 422.
- Unhandled bugs give 500 to the client and the traceback to the log - never send internals to the client.
Quick check before you move on
Interview questions
How do you implement a consistent error format in FastAPI?
Register exception handlers for StarletteHTTPException (covers framework 404/405 and raised HTTPExceptions), RequestValidationError (422) and custom domain exceptions, each returning the same JSON envelope; optionally a generic Exception handler for 500s with a fixed message.
Why define custom exception classes instead of raising HTTPException everywhere?
Domain code stays independent of HTTP, exceptions can carry structured data, and the mapping to status codes and messages lives in one place, reusable from services, jobs and tests.
What should a client see on an unexpected server error?
A 500 with a generic message (and maybe a request id) - never stack traces or exception text; full details go to logs and error tracking.
Quiz
- 1.
How did withdraw() stop with 404 for an unknown account, without its own check?
- 2.
Why does our http_error handler pass headers=exc.headers?
- 3.
Why did our test use raise_server_exceptions=False?
- 4.
What did logger.exception print inside a handler?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...