← Back to FastAPI
Lesson 3.2 · Responses & Errors

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.

Intermediate35 min

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.

workflowWhere an error goesstep 1 / 3

1 - A helper raises HTTPException

get_balance("Z9") raises HTTPException(404). withdraw() and balance() stop too - no extra if-checks needed.

default
{"detail": "Account Z9 not found"}
with handler
{"error": {"code": "HTTP_404", ...}}
status
404
unknown path
same format

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.

Small dictionary
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.

Example 1 - errors.py
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 ZeroDivisionError

Before: 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.

Output - without our handlers
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 Error

Example 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"}.

Example 2 - handlers.py
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.

Output - with our handlers
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 Error

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

Output - handler for fastapi.HTTPException only
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.

Output - uvicorn log
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 Found
Output - TestClient with default settings
in tests the bug is raised: ZeroDivisionError division by zero

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

A catch-all handler
@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"}})
Output
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 zero

Watch 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 error

From any function.

raise HTTPException(status_code=404, detail="Not found")
Extra headers

Sent with the error.

HTTPException(401, "Login needed", headers={"WWW-Authenticate": "Bearer"})
Own error

For business rules.

class NotEnoughMoney(Exception): ...
Handler

Exception -> response.

@app.exception_handler(NotEnoughMoney)
def h(request, exc): return JSONResponse(...)
All HTTP errors

Includes unknown paths.

@app.exception_handler(StarletteHTTPException)
All 422s

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

Wrong method

“Call GET /withdraw/A1/10 with the handlers on. Which code and format do you get?”

Another business error

“Add AccountFrozen(Exception) for account "B2" and a handler that answers 423 Locked.”

Body errors

“Add an endpoint with a Pydantic body and send a wrong body. What does "field" look like now?”

See the trace

“Run the app with uvicorn, open /crash in the browser, and find the traceback in the terminal.”

What usually goes wrong

Returning an error instead of raising it

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")
Handling only fastapi.HTTPException

Unknown paths still had the old format. Handle StarletteHTTPException.

Forgetting exc.headers

A handler that builds a new response loses headers like WWW-Authenticate unless it passes headers=exc.headers.

Sending str(exc) to the client

Internal messages can leak paths, SQL or secrets. Send a fixed message; the log has the details.

Business errors without a handler

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.

1.

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

What did NotEnoughMoney return before it had a handler?
500 Internal Server Error.
Why handle StarletteHTTPException and not fastapi.HTTPException?
FastAPI’s own 404 for unknown paths is raised as the Starlette class, which a handler for the FastAPI child class does not catch.
Which exception is behind every 422?
RequestValidationError.
Where did the traceback of /crash appear?
In the uvicorn log - the client only saw 500 Internal Server Error.

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

    How did withdraw() stop with 404 for an unknown account, without its own check?

  2. 2.

    Why does our http_error handler pass headers=exc.headers?

  3. 3.

    Why did our test use raise_server_exceptions=False?

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