Custom Exception Handling¶
Map domain exceptions to appropriate HTTP responses.
Goal¶
Convert service-level exceptions into meaningful HTTP error responses.
Prerequisites¶
- A controller extending
ControllerorTransactionController - Domain exceptions defined in your service
The Pattern¶
Override handle_exception() in your controller:
from typing import Any
from fastapi import HTTPException, status
def handle_exception(self, exception: Exception) -> Any:
if isinstance(exception, YourDomainError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exception),
) from exception
return super().handle_exception(exception)
Step-by-Step¶
1. Define Domain Exceptions¶
Create exceptions in your service file:
# src/core/order/services.py
from core.exceptions import ApplicationError
class OrderNotFoundError(ApplicationError):
"""Raised when an order cannot be found."""
class OrderAlreadyPaidError(ApplicationError):
"""Raised when trying to pay an already paid order."""
class InsufficientStockError(ApplicationError):
"""Raised when stock is insufficient for order."""
class InvalidOrderStateError(ApplicationError):
"""Raised when order operation is invalid for current state."""
2. Raise Exceptions in Service¶
@dataclass(kw_only=True)
class OrderService:
def get_order_by_id(self, order_id: int) -> Order:
try:
return Order.objects.get(id=order_id)
except Order.DoesNotExist as e:
raise OrderNotFoundError(f"Order {order_id} not found") from e
def pay_order(self, order_id: int) -> Order:
order = self.get_order_by_id(order_id)
if order.status == OrderStatus.PAID:
raise OrderAlreadyPaidError(f"Order {order_id} is already paid")
if order.status != OrderStatus.PENDING:
raise InvalidOrderStateError(
f"Cannot pay order in {order.status} state"
)
order.status = OrderStatus.PAID
order.save()
return order
3. Map Exceptions in Controller¶
# src/delivery/http/controllers/order/controllers.py
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, HTTPException, status
from core.order.services import (
InsufficientStockError,
InvalidOrderStateError,
OrderAlreadyPaidError,
OrderNotFoundError,
OrderService,
)
from infrastructure.delivery.controllers import TransactionController
@dataclass(kw_only=True)
class OrderController(TransactionController):
_order_service: OrderService
def register(self, registry: APIRouter) -> None:
registry.add_api_route(
path="/v1/orders/{order_id}/pay",
endpoint=self.pay_order,
methods=["POST"],
)
def pay_order(self, order_id: int) -> OrderSchema:
order = self._order_service.pay_order(order_id)
return OrderSchema.model_validate(order, from_attributes=True)
def handle_exception(self, exception: Exception) -> Any:
# 404 - Resource not found
if isinstance(exception, OrderNotFoundError):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exception),
) from exception
# 409 - Conflict (already in desired state)
if isinstance(exception, OrderAlreadyPaidError):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exception),
) from exception
# 422 - Unprocessable (invalid state for operation)
if isinstance(exception, InvalidOrderStateError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exception),
) from exception
# 400 - Bad request (insufficient stock)
if isinstance(exception, InsufficientStockError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exception),
) from exception
# Re-raise unknown exceptions
return super().handle_exception(exception)
Exception to HTTP Status Mapping¶
| Exception Type | HTTP Status | When to Use |
|---|---|---|
NotFoundError |
404 | Resource doesn't exist |
AccessDeniedError |
403 | User can't access resource |
AlreadyExistsError |
409 | Resource already exists |
InvalidStateError |
422 | Operation invalid for current state |
ValidationError |
400 | Input validation failed |
InsufficientError |
400 | Not enough resources |
UnauthorizedError |
401 | Authentication required/failed |
Structured Error Responses¶
For more detailed error responses, create an error schema:
# src/delivery/http/controllers/common/schemas.py
from pydantic import BaseModel
class ErrorResponseSchema(BaseModel):
error: str
code: str
details: dict | None = None
Then use it in exception handling:
def handle_exception(self, exception: Exception) -> Any:
if isinstance(exception, InsufficientStockError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": str(exception),
"code": "INSUFFICIENT_STOCK",
"details": {
"requested": exception.requested,
"available": exception.available,
},
},
) from exception
Multiple Exception Types¶
Handle multiple similar exceptions together:
def handle_exception(self, exception: Exception) -> Any:
# Group 404 errors
not_found_errors = (
OrderNotFoundError,
ProductNotFoundError,
UserNotFoundError,
)
if isinstance(exception, not_found_errors):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exception),
) from exception
# Group permission errors
permission_errors = (
AccessDeniedError,
InsufficientPermissionsError,
)
if isinstance(exception, permission_errors):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exception),
) from exception
return super().handle_exception(exception)
Testing Exception Handling¶
@pytest.mark.django_db(transaction=True)
class TestOrderController:
def test_pay_order_not_found(
self,
test_client_factory: TestClientFactory,
user: User,
) -> None:
with test_client_factory(auth_for_user=user) as client:
response = client.post("/v1/orders/99999/pay")
assert response.status_code == HTTPStatus.NOT_FOUND
assert "not found" in response.json()["detail"].lower()
def test_pay_order_already_paid(
self,
test_client_factory: TestClientFactory,
user: User,
paid_order: Order,
) -> None:
with test_client_factory(auth_for_user=user) as client:
response = client.post(f"/v1/orders/{paid_order.id}/pay")
assert response.status_code == HTTPStatus.CONFLICT
Best Practices¶
- Be specific: Use descriptive exception classes, not generic
Exception - Include context: Pass relevant IDs and values in exception messages
- Use appropriate status codes: Follow HTTP semantics
- Chain exceptions: Use
from exceptionto preserve stack trace - Call super(): Always call
super().handle_exception()for unknown exceptions