Skip to content

Centralized Exception Handling

The current API works for the happy path. POST a unique project, get a 201. POST the same payload twice and the second request crashes:

Text
HTTP/1.1 500 Internal Server Error
{"detail":"Internal Server Error"}

A new pytest test, test_create_duplicate_project_fails, exposes the problem. Add it to tests/test_api.py and run the suite:

tests/test_api.py
def test_create_duplicate_project_fails(
    client: TestClient, sample_project_id: int
):
    response = client.post(
        "/projects/",
        json={
            "name": "Release Platform",  # Same name as sample_project_id
            "description": "Another description",
        },
    )
    assert response.status_code == 409
    assert response.json() == {
        "detail": "Data conflict occurred (e.g., duplicate entry)."
    }

Without a handler, the assertion fails as 500 Server Error.

Why 409?

HTTP 409 Conflict signals the request can't be completed because it conflicts with currently stored data. A duplicate name violates the unique constraint on the projects table.

Instead of throwing a 500 error, we want to give actionable information about why the request failed to the client. Let's fix it by catching the underlying IntegrityError once and converting it to an informative response.

Where to catch IntegrityError

Inline try/except blocks in every route that calls session.commit() would work, but they duplicate the same five lines on every write endpoint and (worse) would require FastAPI imports into crud.py. A crud.py that imports HTTPException couples the data layer to the web framework. Instead, the code should be easy to test in isolation.

FastAPI's @app.exception_handler() decorator solves both problems. It registers a function that runs whenever a specific exception type bubbles up out of a route, dependency, or anywhere else inside a request. Routes stay focused on the happy path, crud.py stays free of HTTP knowledge, and the conversion to a 409 happens in one place.

The IntegrityError handler

Register the handler in main.py:

src/release_tracker/main.py
from fastapi import Request, status
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError

# ... app initialization ...


@app.exception_handler(IntegrityError)
def handle_integrity_error(request: Request, exc: IntegrityError):
    return JSONResponse(
        status_code=status.HTTP_409_CONFLICT,
        content={"detail": "Data conflict occurred (e.g., duplicate entry)."},
    )

Any IntegrityError that escapes from anywhere during a request is intercepted by handle_integrity_error and converted into a 409 with a descriptive body. test_create_duplicate_project_fails passes.

Notice the handler doesn't call session.rollback().

It doesn't have to: the session lives inside get_session's with Session(...) block, and that block exits automatically when the route raises, which rolls back any uncommitted transaction.