Error Handling

HTTPException, custom handlers, and keeping HTTP concerns out of the code that does the work.

Overview

The ordinary case

raise HTTPException(status.HTTP_404_NOT_FOUND, "Module not found")

It can be raised anywhere in the call stack, not just in the handler, and FastAPI turns it into a response with that status and {"detail": ...}.

Two properties are worth noticing. It is an exception, so it unwinds — a function five levels deep can refuse the request without every caller checking a return value. And the response shape is consistent, so every client can rely on detail being there.

detail does not have to be a string. A dict or list is serialised, which 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
Output

It can carry headers

Some statuses are meaningless without one — 401 wants WWW-Authenticate, 429 wants Retry-After.

example_02.pyFastAPI
Output

Your own envelope

Override the handler for HTTPException and every raise in the app returns the shape your clients expect.

example_03.pyFastAPI
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
Output

Catching everything else

A handler for Exception turns an unhandled error into a clean 500. Never return the traceback.

example_05.pyFastAPI
Output

Validation errors keep their own handler

RequestValidationError is separate from HTTPException, so the two can have different shapes.

example_06.pyFastAPI
Output

Headers on an error

HTTPException takes headers=, and several statuses are incomplete without one.

A 401 should carry WWW-Authenticate describing the scheme. A 429 should carry Retry-After. A 405 should carry Allow.

Clients act on these automatically. Omitting them makes the response technically correct and practically unhelpful.

Replacing the envelope

If your API has an established error format, override the default handler:

@app.exception_handler(HTTPException)
async def as_problem(request, exc):
    return JSONResponse(status_code=exc.status_code, content={...})

Every HTTPException in the app now returns your shape. Two things to remember: pass the status through rather than hard-coding one, and forward exc.headers, or the Retry-After you carefully set disappears.

Worth doing once, early, if you are 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 should not import fastapi. A function that looks up a module and cannot 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 respond to.

Then map it once:

@app.exception_handler(ModuleNotFound)
async def not_found(request, exc):
    return JSONResponse(status_code=404, content={"detail": ...})

The benefits 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 different transport without rewriting its errors.

The cost is a handler per error class, which is a few lines each and worth it past a handful of endpoints.

The catch-all

A handler for Exception catches anything you did not anticipate:

@app.exception_handler(Exception)
async def unhandled(request, exc):
    logger.exception("unhandled")
    return JSONResponse(status_code=500, content={"detail": "Something went wrong"})

Three rules for it.

Log the real exception, with a request identifier, so the failure is diagnosable.

Return something generic. A traceback in a response body is an information leak — it names files, line numbers, library versions and sometimes values — and it is the kind that ends up in a screenshot in a public issue.

Include a correlation id the caller can quote. That turns "it broke" into a log line you can find.

Note that this handler does not run in the same way when the app is in debug mode or under some test configurations, where 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 separation is useful: validation failures are structured, mechanical and want a field-oriented shape, while raised errors are single messages. Handling them together forces one envelope onto two different kinds of problem.

There is also ResponseValidationError for when your own handler returns something the response model rejects. That one should be loud in logs — it means your code broke its own contract, and no caller can do anything about it.

What not to do

Do not return errors instead of raising them. A handler that returns {"error": "..."} with a 200 defeats every client's error handling.

Do not catch and swallow. A bare except Exception: pass around a database call turns a failure into a wrong answer.

Do not leak internals in detail. "Module not found" is right; the SQL that failed is not.

Do not use 500 for a caller's mistake. If they could have avoided it, it is a 4xx. A 500 means your code failed, and it should page somebody.

A worked shape

For an app of any size, the arrangement 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 validation failures. A handler for Exception logs and returns a generic 500 with an id.

Five handlers, written once, and every endpoint after that just raises what it means.

Mistakes people make

Returning errors instead of raising them. A handler returning {"error": ...} with a 200 defeats every client's error handling, and cannot be produced 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 database call turns a failure into a wrong answer, which is far worse than an error.

Returning the traceback. It names files, line numbers, library versions and sometimes values. Log it; send an id.

Hard-coding the status in a custom HTTPException handler. Every non-404 then returns the wrong code.

Dropping exc.headers in that handler. The Retry-After you carefully attached silently disappears.

500 for something the caller did. If a different request would have worked, it is a 4xx.

Where the layers sit

It helps to see the whole arrangement 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 everything raised directly.

One handler for RequestValidationError shapes validation failures, which want a field-oriented format the others do not.

One handler for Exception logs and returns a generic 500 with a correlation id.

Five handlers written once. After that every 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 everything 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 useful; "error" is not; 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 does not belong: internal 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 established format - RFC 7807 problem details, say - use it, and use it everywhere 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 cannot become a clean error, because the status line and headers have already been sent.

That is why a StreamingResponse whose generator fails mid-way produces a truncated body rather than a 500. The client sees a connection that ended early, which is indistinguishable from a network failure.

The mitigation is to do the work that can fail before you start streaming, and to keep generators simple. It is also a reason not to reach for streaming unless the payload genuinely needs it.

Testing the failures

Error paths are the least-tested part of most applications, and the easiest to test here.

A test that a missing module gives 404, that a duplicate gives 409, and 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, and a suite that fails on wording is a suite people learn to ignore.

Errors as part of the contract

The failure modes of an API are part of its interface, and 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 different data" or "this already happened, you are done"; and whether an error response is stable enough to branch on.

Answering those in documentation 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 are written.

The alternative - discovering the error contract by causing failures in production - is what most integrations actually do, and it is 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 produces the same status and shape, so a client can be written once.

They are actionable: the message says what to change, and the status says whether changing anything would help.

They are diagnosable: when something is genuinely broken, both sides can refer to the same identifier.

Everything in this module is in service of those. The handlers, the domain exceptions, the envelope and the logging are mechanisms; the properties are the point, and they are what a caller notices.

Summary

HTTPException can be raised anywhere in the call stack and becomes a consistent JSON body. It takes headers, which several statuses require.

Business logic should raise domain exceptions and import nothing from the framework; one handler per exception class maps them to statuses at the edge, which 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 generic message plus an id. Validation keeps its own handler, because field-oriented failures want a different shape from single-message ones.

Next

Structure comes next: splitting a growing application into routers, so that the error handlers, the models and the endpoints each live somewhere a newcomer can find them - and so that main.py stays short enough to read in one screen.

A final note

The arrangement described here - domain exceptions raised low, mapped once at the edge - is worth adopting earlier than it feels necessary.

Retrofitting it means finding every HTTPException scattered through service code and deciding what each should have been, usually while also changing something 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.

  1. Where can `HTTPException` be raised?

  2. Why raise `ModuleNotFound` in a service rather than `HTTPException(404)`?

  3. What should an `Exception` handler return to the client?

  4. You override the `HTTPException` handler. What is easy to forget?

Cheat sheet

Error Handling

It can be raised anywhere in the call stack, not just in the handler, and FastAPI turns it into a response with that status and {"detail": ...}.

FASTAPI · vizlearn.in/fastapi/error_handling.html

About the author

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.