Error Handling

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

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

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.

Then map it once:

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

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:

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

  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

Raise an HTTPException, register a custom exception handler, and keep HTTP status codes out of the business logic that does the real work.

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.