13 KiB
Exception Handling Guide
Overview
The LetzShop API uses a unified custom exception system to provide consistent, meaningful error responses across all endpoints. This system was redesigned to eliminate competing exception handlers and provide a single source of truth for error handling.
Architecture
Exception Hierarchy
LetzShopException (Base)
├── ValidationException (422)
├── AuthenticationException (401)
├── AuthorizationException (403)
├── ResourceNotFoundException (404)
├── ConflictException (409)
├── BusinessLogicException (400)
├── RateLimitException (429)
└── ExternalServiceException (502)
File Structure
app/exceptions/
├── __init__.py # All exception exports
├── base.py # Base exception classes
├── handler.py # Unified FastAPI exception handlers
├── auth.py # Authentication/authorization exceptions
├── admin.py # Admin operation exceptions
├── marketplace.py # Import/marketplace exceptions
├── product.py # MarketplaceProduct management exceptions
├── shop.py # Shop management exceptions
└── inventory.py # Inventory management exceptions
Core Concepts
Unified Exception Handling
The system uses only FastAPI's built-in exception handlers - no middleware or competing systems. All exceptions are processed through a single, consistent pipeline in handler.py.
Exception Response Format
All custom exceptions return a consistent JSON structure:
{
"error_code": "PRODUCT_NOT_FOUND",
"message": "MarketplaceProduct with ID 'ABC123' not found",
"status_code": 404,
"details": {
"resource_type": "MarketplaceProduct",
"identifier": "ABC123"
}
}
HTTP Status Codes
The system uses appropriate HTTP status codes:
- 400: Business logic violations (cannot modify own admin account)
- 401: Authentication failures (invalid credentials, expired tokens)
- 403: Authorization failures (admin privileges required)
- 404: Resource not found (user, shop, product not found)
- 409: Resource conflicts (duplicate shop codes, existing products)
- 422: Validation errors (Pydantic validation failures)
- 429: Rate limiting (too many requests)
- 500: Internal server errors (unexpected exceptions)
Implementation Details
Exception Handler Setup
The main application registers all exception handlers in a single function:
# main.py
from app.exceptions.handler import setup_exception_handlers
app = FastAPI(...)
setup_exception_handlers(app) # Single registration point
Handler Types
The system handles four categories of exceptions:
# app/exceptions/handler.py
@app.exception_handler(LetzShopException)
async def custom_exception_handler(request, exc):
"""Handle all custom business exceptions"""
return JSONResponse(
status_code=exc.status_code,
content=exc.to_dict()
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
"""Handle FastAPI HTTP exceptions"""
return JSONResponse(
status_code=exc.status_code,
content={
"error_code": f"HTTP_{exc.status_code}",
"message": exc.detail,
"status_code": exc.status_code,
}
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
"""Handle Pydantic validation errors"""
return JSONResponse(
status_code=422,
content={
"error_code": "VALIDATION_ERROR",
"message": "Request validation failed",
"status_code": 422,
"details": {"validation_errors": exc.errors()}
}
)
@app.exception_handler(Exception)
async def generic_exception_handler(request, exc):
"""Handle unexpected exceptions"""
return JSONResponse(
status_code=500,
content={
"error_code": "INTERNAL_SERVER_ERROR",
"message": "Internal server error",
"status_code": 500,
}
)
Working with Exceptions
Service Layer Pattern
Services raise custom exceptions with specific error codes:
# app/services/admin_service.py
from app.exceptions.admin import UserNotFoundException, CannotModifySelfException
def toggle_user_status(self, db: Session, user_id: int, current_admin_id: int):
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent self-modification
if user.id == current_admin_id:
raise CannotModifySelfException(user_id, "deactivate account")
# Prevent admin-to-admin modification
if user.role == "admin" and user.id != current_admin_id:
raise UserStatusChangeException(
user_id=user_id,
current_status="admin",
attempted_action="toggle status",
reason="Cannot modify another admin user"
)
Authentication Integration
Authentication dependencies use custom exceptions instead of HTTPException:
# middleware/auth.py
from app.exceptions.auth import AdminRequiredException, TokenExpiredException
def require_admin(self, current_user: User):
if current_user.role != "admin":
raise AdminRequiredException() # Returns error_code: "ADMIN_REQUIRED"
return current_user
def verify_token(self, token: str):
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
# ... token verification logic
except jwt.ExpiredSignatureError:
raise TokenExpiredException() # Returns error_code: "TOKEN_EXPIRED"
Endpoint Implementation
Endpoints rely on service layer exceptions and don't handle HTTPException:
# app/api/v1/admin.py
@router.put("/admin/users/{user_id}/status")
def toggle_user_status(
user_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user), # May raise AdminRequiredException
):
# Service raises UserNotFoundException, CannotModifySelfException, etc.
user, message = admin_service.toggle_user_status(db, user_id, current_admin.id)
return {"message": message}
Creating New Exceptions
Step 1: Define Domain-Specific Exceptions
Create exceptions in the appropriate module:
# app/exceptions/payment.py
from .base import BusinessLogicException, ResourceNotFoundException
class PaymentNotFoundException(ResourceNotFoundException):
"""Raised when payment record is not found."""
def __init__(self, payment_id: str):
super().__init__(
resource_type="Payment",
identifier=payment_id,
message=f"Payment with ID '{payment_id}' not found",
error_code="PAYMENT_NOT_FOUND",
)
class InsufficientFundsException(BusinessLogicException):
"""Raised when account has insufficient funds."""
def __init__(self, required: float, available: float, account_id: str):
super().__init__(
message=f"Insufficient funds. Required: {required}, Available: {available}",
error_code="INSUFFICIENT_FUNDS",
details={
"required_amount": required,
"available_amount": available,
"account_id": account_id,
},
)
Step 2: Export in init.py
# app/exceptions/__init__.py
from .payment import PaymentNotFoundException, InsufficientFundsException
__all__ = [
# ... existing exports
"PaymentNotFoundException",
"InsufficientFundsException",
]
Step 3: Use in Services
# app/services/payment_service.py
from app.exceptions import PaymentNotFoundException, InsufficientFundsException
def process_payment(self, db: Session, payment_id: str, amount: float):
payment = self._get_payment_by_id_or_raise(db, payment_id)
if payment.account_balance < amount:
raise InsufficientFundsException(amount, payment.account_balance, payment.account_id)
# Process payment...
return payment
Testing Exception Handling
Unit Tests
Test exception properties and inheritance:
# tests/unit/exceptions/test_admin_exceptions.py
import pytest
from app.exceptions.admin import UserNotFoundException, CannotModifySelfException
def test_user_not_found_exception():
exc = UserNotFoundException("123")
assert exc.error_code == "USER_NOT_FOUND"
assert exc.status_code == 404
assert "123" in exc.message
assert exc.details["identifier"] == "123"
def test_cannot_modify_self_exception():
exc = CannotModifySelfException(456, "deactivate account")
assert exc.error_code == "CANNOT_MODIFY_SELF"
assert exc.status_code == 400
assert "deactivate account" in exc.message
Integration Tests
Test complete error handling flow:
# tests/integration/api/v1/test_admin_endpoints.py
def test_toggle_user_status_cannot_modify_self(client, admin_headers, test_admin):
"""Test that admin cannot modify their own account"""
response = client.put(
f"/api/v1/admin/users/{test_admin.id}/status",
headers=admin_headers
)
assert response.status_code == 400
data = response.json()
assert data["error_code"] == "CANNOT_MODIFY_SELF"
assert "Cannot perform 'deactivate account' on your own account" in data["message"]
def test_get_all_users_non_admin(client, auth_headers):
"""Test non-admin trying to access admin endpoint"""
response = client.get("/api/v1/admin/users", headers=auth_headers)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "ADMIN_REQUIRED" # Custom exception, not HTTP_403
assert "Admin privileges required" in data["message"]
Migration from Legacy System
What Was Changed
- Removed competing handlers: Eliminated
ExceptionHandlerMiddlewareand duplicate exception handling - Unified response format: All exceptions now return consistent JSON structure
- Enhanced authentication: Replaced
HTTPExceptionin auth layer with custom exceptions - Improved logging: Added structured logging with request context
Breaking Changes
- Authentication failures now return
ADMIN_REQUIREDinstead ofHTTP_403 - Rate limiting returns structured error with
RATE_LIMIT_EXCEEDEDcode - All custom exceptions use 400 for business logic errors (not 422)
Migration Steps
- Update exception imports: Replace
HTTPExceptionimports with custom exceptions - Fix test expectations: Update tests to expect specific error codes (e.g.,
ADMIN_REQUIRED) - Remove legacy handlers: Delete any remaining middleware or duplicate handlers
- Update frontend: Map new error codes to user-friendly messages
Error Code Reference
Authentication (401)
INVALID_CREDENTIALS: Invalid username or passwordTOKEN_EXPIRED: JWT token has expiredINVALID_TOKEN: Malformed or invalid tokenUSER_NOT_ACTIVE: User account is deactivated
Authorization (403)
ADMIN_REQUIRED: Admin privileges required for operationINSUFFICIENT_PERMISSIONS: User lacks required permissionsUNAUTHORIZED_SHOP_ACCESS: Cannot access shop (not owner)
Resource Not Found (404)
USER_NOT_FOUND: User with specified ID not foundSHOP_NOT_FOUND: Shop with specified code/ID not foundPRODUCT_NOT_FOUND: MarketplaceProduct with specified ID not found
Business Logic (400)
CANNOT_MODIFY_SELF: Admin cannot modify own accountUSER_STATUS_CHANGE_FAILED: Cannot change user statusSHOP_VERIFICATION_FAILED: Shop verification operation failedADMIN_OPERATION_FAILED: Generic admin operation failure
Validation (422)
VALIDATION_ERROR: Pydantic request validation failedINVALID_PRODUCT_DATA: MarketplaceProduct data validation failedINVALID_SHOP_DATA: Shop data validation failed
Rate Limiting (429)
RATE_LIMIT_EXCEEDED: API rate limit exceeded
Service Errors (500/502)
INTERNAL_SERVER_ERROR: Unexpected server errorEXTERNAL_SERVICE_ERROR: Third-party service failure
Best Practices
Exception Design
- Use specific error codes for programmatic handling
- Include relevant context in exception details
- Inherit from appropriate base exception class
- Provide clear, actionable error messages
Service Layer
- Raise custom exceptions, never HTTPException
- Include identifiers and context in exception details
- Use private helper methods with
_or_raisesuffix - Log errors before raising exceptions
Testing
- Test both exception raising and response format
- Verify error codes, status codes, and message content
- Test edge cases and error conditions
- Use custom exceptions in test assertions
Frontend Integration
- Map error codes to user-friendly messages
- Handle field-specific validation errors
- Implement retry logic for transient errors
- Log errors for debugging and monitoring
This unified exception system provides consistent error handling across your entire application while maintaining clean separation of concerns and excellent developer experience.