Custom Exception Handling¶
This guide explains how to handle domain exceptions in controllers and map them to appropriate HTTP status codes.
How Exception Handling Works¶
Controllers extend the Controller base class, which automatically wraps all public methods with exception handling. When an exception occurs, the handle_exception() method is called.
class Controller(ABC):
@abstractmethod
def register(self, registry: Any) -> None: ...
def handle_exception(self, exception: Exception) -> Any:
raise exception # Default: re-raise the exception
By default, exceptions are re-raised. Override handle_exception() to customize error responses.
Basic Pattern¶
Step 1: Define Domain Exceptions¶
Domain exceptions belong in your service module:
# core/orders/services.py
class OrderNotFoundError(Exception):
"""Raised when an order is not found."""
class OrderAlreadyShippedError(Exception):
"""Raised when attempting to modify a shipped order."""
class InsufficientInventoryError(Exception):
"""Raised when there is not enough inventory."""
Step 2: Override handle_exception() in Controller¶
# delivery/http/orders/controllers.py
from http import HTTPStatus
from typing import Any
from fastapi import HTTPException
from core.orders.services import (
InsufficientInventoryError,
OrderAlreadyShippedError,
OrderNotFoundError,
OrderService,
)
from infrastructure.delivery.controllers import Controller
class OrderController(Controller):
def __init__(self, order_service: OrderService) -> None:
self._order_service = order_service
def register(self, registry: APIRouter) -> None:
# ... register routes ...
def handle_exception(self, exception: Exception) -> Any:
if isinstance(exception, OrderNotFoundError):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=str(exception),
) from exception
if isinstance(exception, OrderAlreadyShippedError):
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Cannot modify a shipped order",
) from exception
if isinstance(exception, InsufficientInventoryError):
raise HTTPException(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
detail=str(exception),
) from exception
# Fall back to default behavior for unhandled exceptions
return super().handle_exception(exception)
Real-World Example: UserTokenController¶
Here is how the template handles refresh token exceptions:
# delivery/http/user/controllers.py
from http import HTTPStatus
from typing import Any
from fastapi import HTTPException
from core.user.services.refresh_session import (
ExpiredRefreshTokenError,
InvalidRefreshTokenError,
RefreshTokenError,
)
from infrastructure.delivery.controllers import Controller
class UserTokenController(Controller):
def __init__(
self,
jwt_auth_factory: JWTAuthFactory,
jwt_service: JWTService,
refresh_token_service: RefreshSessionService,
user_service: UserService,
) -> None:
self._jwt_auth = jwt_auth_factory()
self._jwt_service = jwt_service
self._refresh_token_service = refresh_token_service
self._user_service = user_service
def register(self, registry: APIRouter) -> None:
# ... route registration ...
def handle_exception(self, exception: Exception) -> Any:
if isinstance(exception, InvalidRefreshTokenError):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid refresh token",
) from exception
if isinstance(exception, ExpiredRefreshTokenError):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Refresh token expired or revoked",
) from exception
if isinstance(exception, RefreshTokenError):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Refresh token error",
) from exception
return super().handle_exception(exception)
Exception Chaining¶
Always Chain Exceptions
Use from exception when raising HTTPException to preserve the exception chain for debugging and logging.
# Correct: preserves exception chain
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Order not found",
) from exception
# Incorrect: loses original exception context
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Order not found",
)
Common HTTP Status Codes¶
| Status Code | Use Case |
|---|---|
400 BAD_REQUEST |
Invalid input, validation errors |
401 UNAUTHORIZED |
Authentication required or failed |
403 FORBIDDEN |
Authenticated but not authorized |
404 NOT_FOUND |
Resource does not exist |
409 CONFLICT |
Resource state conflict (e.g., duplicate) |
422 UNPROCESSABLE_ENTITY |
Valid syntax but semantic errors |
429 TOO_MANY_REQUESTS |
Rate limit exceeded |
Exception Hierarchy Pattern¶
For complex domains, create an exception hierarchy:
# core/payments/services.py
class PaymentError(Exception):
"""Base exception for payment errors."""
class PaymentNotFoundError(PaymentError):
"""Payment does not exist."""
class PaymentDeclinedError(PaymentError):
"""Payment was declined by the processor."""
class PaymentAlreadyProcessedError(PaymentError):
"""Payment has already been processed."""
Then handle the base class as a fallback:
def handle_exception(self, exception: Exception) -> Any:
if isinstance(exception, PaymentNotFoundError):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=str(exception),
) from exception
if isinstance(exception, PaymentDeclinedError):
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail="Payment was declined",
) from exception
if isinstance(exception, PaymentAlreadyProcessedError):
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Payment has already been processed",
) from exception
# Catch any other PaymentError
if isinstance(exception, PaymentError):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Payment error occurred",
) from exception
return super().handle_exception(exception)
Summary¶
- Define domain exceptions in your service module
- Override
handle_exception()in your controller - Use
isinstance()to check exception types - Map exceptions to appropriate HTTP status codes using
HTTPException - Always chain exceptions with
from exception - Call
super().handle_exception(exception)for unhandled cases