FastAPI exception handling: stop domain errors as 500s

FastAPI exception handling for business rules: why refusals leaked out as 500s, when to use 400, 409 or 422, and one handler that keeps every error readable.

By

Most FastAPI exception handling guides cover HTTPException and request validation. The errors that hurt me were a different kind: domain refusals. A user tries to move a record to a status it cannot reach, delete something other records still point at, or act through an account that is not allowed to. The service knows exactly what went wrong and raises an exception. Then the route has no mapping for it, and the caller gets a 500.

I spent a good part of one sprint on a compliance platform, a FastAPI backend with a React frontend, fixing this class of bug again and again. This post covers what kept going wrong, how I pick status codes for business rule errors, and the setup I'd use so an unmapped refusal never reaches a user.

How domain errors turned into 500s

The services raised typed exceptions, and each router kept an error map that translated those exceptions into HTTP responses. Anything missing from the map fell through to the default handler: a 500 with no useful body.

That map was the weak point. Within a few days:

  • New refusals returned 500 because nobody had added them to the router's map. My commit message called it "the third time this sprint an unmapped exception has only shown up under a real call."
  • A guard's refusal surfaced as a 500 for the same reason, "the failure this codebase already carries a written scar about." It is now a 409 that tells the caller what to do.
  • An illegal status move raised a bare ValueError the route did not map, so it returned 500. It now returns 409 with the reason, "so a caller that is not the UI learns what was wrong."
  • A service-account refusal returned 500 instead of 400, again because the route's map did not list it.

The unit tests called the service and asserted that the exception was raised, and they all passed. Nothing asserted what the HTTP layer did with it. That gap is the first thing I look for when reviewing AI-generated tests, and it applies just as much to tests people write.

Wrong status codes are bugs too

A 500 at least looks broken. The quieter bugs were refusals that returned a status code which told the caller something false.

A foreign-key failure surfaced as a 503, which says the service is unavailable and the client should try again later. No number of retries would ever have worked, because the row it referenced did not exist. Deleting a risk that other records still linked to also returned 503. It now returns 409 and names what is still attached, so the person knows what to unlink first.

Database errors translated too broadly cause the same problem. Opening a collection run turned any IntegrityError into "a collection run is already in flight." Some of those integrity errors had nothing to do with a running job. Now that message appears only when a queued or running row is actually found. I covered the useful side of unique constraint errors in fixing race condition duplicate rows with a unique index.

Messages carry meaning as well as codes. A connector check reported two outcomes, unknown and unreachable, so a source that had simply never been set up was reported as unreachable. I added a distinct "connector not configured" error, because "telling somebody a source could not be reached sends them looking for a fault that is not there."

Should a business rule error return 400, 409 or 422?

Use 409 Conflict when the request is well formed but clashes with the current state of the resource: an illegal status transition, deleting something still referenced, a job already running. Use 400 when the domain rejects the request whatever the state. Leave 422 to FastAPI's own request validation, so a client can tell "your payload was malformed" from "the business said no". A refusal should never be a 5xx, because 5xx means the server failed and a retry might help.

RFC 9110 defines 409 as a conflict with the current state of the target resource, which fits most workflow refusals exactly.

Keep 422 for request validation

I learned this one from a real mix-up. Import errors were mapped to 422, and that mapping was merged into the whole router's map. From the client's side, a domain error on import now looked identical to FastAPI's own validation failure, which also uses 422. My commit note: "The library warns about exactly this, 1098 times." The import errors are 400 now, and the client tells them apart by the error body.

Validation 422s can hide real bugs too. A commit route took run_id from the path and also required it in the request body. "Every request from the browser was a 422 that no test caught," because the tests built the body by hand and always included the field. If a route has a 422 that only real clients ever hit, the tests and the client disagree about the contract.

Make the client read the same error shape

The server half only helps if the client reads it. FastAPI's validation errors arrive in a detail envelope. The platform's domain errors carried a message field. The React app's error helper read data.detail and nothing else, so every domain refusal degraded to "something went wrong." The worst case was a delete dialog that closed as if the delete had worked, on a delete the server had refused.

The rule I keep now: one documented error shape for domain errors, with a stable machine-readable code and a human message, and a client helper with a test for every shape it has to read. A helper that falls back to a generic message hides exactly the errors the backend worked hardest to explain.

FastAPI exception handling with one handler

What I'd set up on day one of a new FastAPI service is a base class for domain errors that carries its own status and code, plus one handler registered for that base class. The FastAPI docs on handling errors show @app.exception_handler for custom exceptions, and Starlette looks handlers up through the exception's class hierarchy, so one handler covers every subclass.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

class DomainError(Exception):
    status_code = 400
    code = "domain_error"

    def __init__(self, message: str):
        self.message = message

class StateConflict(DomainError):
    status_code = 409
    code = "state_conflict"

app = FastAPI()

@app.exception_handler(DomainError)
async def handle_domain_error(request: Request, exc: DomainError):
    return JSONResponse(
        status_code=exc.status_code,
        content={"code": exc.code, "message": exc.message},
    )

Adding a refusal now means adding a subclass with a status. There is no second place to remember, and a forgotten status defaults to a 400 with a readable message instead of a 500. A bare ValueError still falls through, so I'd also make raising built-in exceptions for business rules something code review rejects.

Two tests guard the setup. One calls a real route and asserts both the status and the body of a refusal. The other walks every DomainError subclass and fails if any of them declares a 5xx status.

The short version

  • A refusal the code understands should never come back as a 500 or a 503.
  • Use 409 for conflicts with current state, 400 for domain rejections, and leave 422 to request validation.
  • Translate a database error into a friendly message only after confirming the cause.
  • Keep one base class, one handler and one documented error body, and make the client read it.
  • Test refusals through the HTTP layer, since that is where every one of these bugs lived.

If your FastAPI service sits beside a Node.js backend, the same rules apply at that seam, and I described that setup in running a Python AI microservice alongside a Node.js backend. If you want help with an API like this, see my services.

Want this kind of work on your product?

Get in touch