HTTPException vs Custom Exception Classes in FastAPI

Key takeaways:

  • HTTPException belongs at the edge; a domain exception belongs anywhere the business rule lives.
  • Give domain exceptions a code and a status on the class, and register one handler for the base.
  • Starlette resolves handlers by walking the exception's MRO, so a base-class handler catches every subclass.
  • A service that raises HTTPException cannot be reused by a worker, a CLI or a test without importing FastAPI.
  • Handlers do not fire for exceptions raised in background tasks or after a response has started streaming.

This guide is part of Error Handling and Global Exceptions, which covers making error responses consistent; this page covers the decision that comes first — what to raise in the first place.

The Problem This Solves

A withdrawal fails because the balance is too low. Somewhere that fact has to become a 409 with a machine-readable code and enough context for the client to show a useful message. The question is where.

The path of least resistance is raise HTTPException(status_code=409, detail="Insufficient funds") inside the function that checks the balance. It works, it is one line, and it quietly makes that function unusable outside a web request. The nightly reconciliation job that calls the same logic now depends on FastAPI, and the exception it catches carries an HTTP status code that means nothing in a cron job. Six months later the same rule is expressed twice, in two places, differently.

Domain exception versus HTTPExceptionOn the left, the service layer raises a domain exception which crosses into a registered handler and becomes a structured 409 envelope. On the right, the path operation raises HTTPException directly and Starlette's built-in handler renders a detail body.domain exceptionHTTPExceptionservice layer — imports no FastAPIraise InsufficientFunds(...)path operation — the edgeraise HTTPException(404, ...)your handlerexception_handler(DomainError)built-in handlerStarlette renders it409 · error.code, message, context404 · detail

Why It Happens

Starlette wraps your application in an exception middleware that sits outside the router. When a path operation raises, that middleware catches the exception and looks for a handler by walking type(exc).__mro__ — the exception's class, then its base classes in order — and using the first one that has a registered handler.

Two consequences follow directly, and they are the whole basis of the pattern on this page.

First, HTTPException is not special to the framework's core. FastAPI registers a default handler for it during app construction, exactly the way app.exception_handler(...) registers yours. It converts the exception into a JSONResponse with a detail key and copies across any headers you passed. Nothing stops you from replacing that handler, and nothing makes it more privileged than one of yours.

Second, because the lookup walks the MRO, registering a handler for a base class covers the entire hierarchy beneath it. One @app.exception_handler(DomainError) catches AccountNotFound, InsufficientFunds, and every exception you add next year, without touching the wiring. That is what makes a domain exception hierarchy cheap: the cost of a new error type is one class with two class attributes.

The lookup happens inside the middleware stack, which is also why the pattern has hard edges. A BackgroundTask runs after the response has been sent, so an exception there has no response left to modify. A StreamingResponse that has already emitted its first chunk has committed a status code, so raising afterwards cannot change it. And a middleware that wraps call_next in a bare except swallows the exception before the exception middleware sees it — Middleware Execution Order covers where in the stack that goes wrong.

The Fix

Split the two responsibilities. The service raises domain exceptions carrying a code, a message and structured context. One handler maps that hierarchy onto the wire format. HTTPException stays in the path operation, where the failure genuinely is about HTTP.

"""Contrast raising HTTPException in a route with mapping a domain exception via a handler."""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse


class DomainError(Exception):
    """Base for everything the domain can refuse to do."""

    code = "domain_error"
    status = 400

    def __init__(self, message: str, **context: object) -> None:
        super().__init__(message)
        self.message = message
        self.context = context


class AccountNotFound(DomainError):
    code = "account_not_found"
    status = 404


class InsufficientFunds(DomainError):
    code = "insufficient_funds"
    status = 409


BALANCES = {"acc_1": 500}


def withdraw(account_id: str, amount: int) -> int:
    """Pure domain logic. Importable from a worker, a CLI or a test with no HTTP in sight."""
    if account_id not in BALANCES:
        raise AccountNotFound("no such account", account_id=account_id)
    if BALANCES[account_id] < amount:
        raise InsufficientFunds(
            "balance too low", account_id=account_id, balance=BALANCES[account_id], requested=amount
        )
    BALANCES[account_id] -= amount
    return BALANCES[account_id]

Note what withdraw does not import. No FastAPI, no status codes, no response classes. It is testable with a plain pytest.raises and callable from anywhere.

The HTTP layer is then thin, and there is exactly one place that decides what a domain failure looks like on the wire:

app = FastAPI()


@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError) -> JSONResponse:
    """One place that decides how every domain failure looks on the wire."""
    return JSONResponse(
        status_code=exc.status,
        content={
            "error": {"code": exc.code, "message": exc.message, "context": exc.context},
            "path": request.url.path,
        },
    )


@app.post("/accounts/{account_id}/withdrawals")
async def make_withdrawal(account_id: str, amount: int) -> dict[str, int | str]:
    """No try/except. The service raises; the handler renders."""
    remaining = withdraw(account_id, amount)
    return {"account_id": account_id, "remaining_cents": remaining}


@app.get("/accounts/{account_id}")
async def read_account(account_id: str) -> dict[str, int | str]:
    """HTTPException is right here: this IS the transport concern, raised at the edge."""
    if account_id not in BALANCES:
        raise HTTPException(
            status_code=404,
            detail="Account not found",
            headers={"Cache-Control": "no-store"},
        )
    return {"account_id": account_id, "balance_cents": BALANCES[account_id]}

Both styles, running in the same app, from a real run:

$ POST /accounts/acc_1/withdrawals?amount=100
200 OK
{
  "account_id": "acc_1",
  "remaining_cents": 400
}

$ POST /accounts/acc_1/withdrawals?amount=100000
409 Conflict
{
  "error": {
    "code": "insufficient_funds",
    "message": "balance too low",
    "context": {
      "account_id": "acc_1",
      "balance": 400,
      "requested": 100000
    }
  },
  "path": "/accounts/acc_1/withdrawals"
}

$ POST /accounts/acc_nope/withdrawals?amount=1
404 Not Found
{
  "error": {
    "code": "account_not_found",
    "message": "no such account",
    "context": {
      "account_id": "acc_nope"
    }
  },
  "path": "/accounts/acc_nope/withdrawals"
}

$ GET /accounts/acc_nope
404 Not Found
{
  "detail": "Account not found"
}

Three details in that transcript are the argument for the pattern.

The 409 and the first 404 came from one registered handler. Neither AccountNotFound nor InsufficientFunds has a handler of its own; the MRO walk found the DomainError registration. Adding a CardDeclined with status = 402 requires no change to the HTTP layer at all.

The status code came from the exception class, not from the route. make_withdrawal contains no status codes and no try/except, so the mapping from business failure to HTTP semantics lives in one readable place rather than scattered across handlers.

And the context dictionary carries the actual numbers — balance 400, requested 100000 — which a client can render into a real message. {"detail": "Insufficient funds"} cannot. Note the balance reads 400 and not 500, because the successful withdrawal in the first request genuinely mutated the state before the second request ran.

The last response shows the contrasting shape. HTTPException produces Starlette's detail envelope, and it is the right tool there: "you asked for an account that does not exist in this URL" is a statement about the request, not about the business rule.

Verification

Test the two layers separately, which is the payoff of having separated them:

def test_service_layer_has_no_http_concerns():
    with pytest.raises(InsufficientFunds) as excinfo:
        withdraw("acc_1", 10**9)
    assert excinfo.value.context["requested"] == 10**9


def test_handler_maps_status_and_code(client):
    response = client.post("/accounts/acc_1/withdrawals", params={"amount": 10**9})
    assert response.status_code == 409
    assert response.json()["error"]["code"] == "insufficient_funds"

Then add the guard that keeps the boundary from eroding, because it will erode under deadline pressure:

def test_service_package_never_imports_fastapi():
    source = (Path("app") / "services").rglob("*.py")
    for path in source:
        assert "fastapi" not in path.read_text(), f"{path} imports the web framework"

That test is blunt and it is effective. It fails on the first pull request that puts an HTTPException back into a service, at review time rather than at the point somebody tries to reuse the module.

In production, alert on the error.code field rather than on the status code. A spike in insufficient_funds is a product signal; a spike in account_not_found is usually a caller bug or an enumeration attempt. Both are 4xx and only the code tells them apart.

Trade-offs and When Not To

A hierarchy is overhead on a small API. Three endpoints and two error cases do not need a base class and a handler. Raise HTTPException and move on. The pattern pays for itself when the same business logic has more than one caller, or when the number of error types passes roughly a dozen.

HTTPException is a genuinely good tool at the edge. Authentication failures, unsupported media types, a Retry-After on a throttle — these are HTTP facts, and expressing them as domain exceptions is the mirror-image mistake. The rule is not "never raise HTTPException", it is "never raise it below the layer that knows it is serving HTTP".

Domain exceptions do not document themselves. HTTPException in a route tells a reader nothing about OpenAPI either, but at least the status code is visible. With mapped exceptions the possible status codes of an endpoint are invisible unless you declare them, so add responses={409: {...}} to the decorator for anything a client must handle.

Do not put both mechanisms on the same failure. Catching a domain exception in a route and re-raising it as an HTTPException reintroduces the coupling one layer up and gives you two places to change a status code. If a specific route genuinely needs a different status than the class default, that is a signal the class is too coarse.

Validation errors are a separate track. Pydantic's RequestValidationError has its own handler and its own body shape, and reshaping it is covered in Customising Validation Error Responses. Do not try to fold 422s into a domain hierarchy — they are raised before your code runs.

For the surrounding envelope, logging and correlation-id conventions, see Global Exception Handlers for Consistent API Responses.

FAQ

When should I raise HTTPException instead of a domain exception? Raise HTTPException at the edge, where the failure genuinely is an HTTP concern — a missing path parameter, a failed auth check, a request header you cannot honour. Raise a domain exception whenever the failure is about the business rule, so the code expressing that rule stays callable from a worker or a CLI.

Does one exception handler catch subclasses of the exception it registers? Yes. Starlette looks up handlers by walking the raised exception's method resolution order and using the first registered match, so a handler registered for a DomainError base class catches every subclass. One registration covers a whole hierarchy.

Why does my exception handler not fire? The three usual causes are raising inside a BackgroundTask, which runs after the response has been sent, raising after a streaming response has begun, and catching the exception yourself in a broad try/except in the route. A middleware that swallows exceptions will also shadow it.

How do I keep the error response shape consistent across an API? Give every domain exception a code and a status on the class, register one handler for the base class, and let that single function build the JSON envelope. Every new exception type then inherits the format for free rather than reimplementing it.

Can I attach response headers from an exception?HTTPException takes a headers argument, and a custom handler returns a full JSONResponse so it can set anything — Retry-After on a rate limit, Cache-Control: no-store on a 404 that must not be cached, or a correlation id.