# Exception Handling Guide ## Overview The Orion 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 ``` OrionException (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: ```json { "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: ```python # 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: ```python # app/exceptions/handler.py @app.exception_handler(OrionException) 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: ```python # 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: ```python # 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: ```python # 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_api), # 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: ```python # 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 ```python # app/exceptions/__init__.py from .payment import PaymentNotFoundException, InsufficientFundsException __all__ = [ # ... existing exports "PaymentNotFoundException", "InsufficientFundsException", ] ``` ### Step 3: Use in Services ```python # 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: ```python # 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: ```python # 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 1. **Removed competing handlers**: Eliminated `ExceptionHandlerMiddleware` and duplicate exception handling 2. **Unified response format**: All exceptions now return consistent JSON structure 3. **Enhanced authentication**: Replaced `HTTPException` in auth layer with custom exceptions 4. **Improved logging**: Added structured logging with request context ### Breaking Changes - Authentication failures now return `ADMIN_REQUIRED` instead of `HTTP_403` - Rate limiting returns structured error with `RATE_LIMIT_EXCEEDED` code - All custom exceptions use 400 for business logic errors (not 422) ### Migration Steps 1. **Update exception imports**: Replace `HTTPException` imports with custom exceptions 2. **Fix test expectations**: Update tests to expect specific error codes (e.g., `ADMIN_REQUIRED`) 3. **Remove legacy handlers**: Delete any remaining middleware or duplicate handlers 4. **Update frontend**: Map new error codes to user-friendly messages ## Error Code Reference ### Authentication (401) - `INVALID_CREDENTIALS`: Invalid username or password - `TOKEN_EXPIRED`: JWT token has expired - `INVALID_TOKEN`: Malformed or invalid token - `USER_NOT_ACTIVE`: User account is deactivated ### Authorization (403) - `ADMIN_REQUIRED`: Admin privileges required for operation - `INSUFFICIENT_PERMISSIONS`: User lacks required permissions - `UNAUTHORIZED_SHOP_ACCESS`: Cannot access shop (not owner) ### Resource Not Found (404) - `USER_NOT_FOUND`: User with specified ID not found - `SHOP_NOT_FOUND`: Shop with specified code/ID not found - `PRODUCT_NOT_FOUND`: MarketplaceProduct with specified ID not found ### Business Logic (400) - `CANNOT_MODIFY_SELF`: Admin cannot modify own account - `USER_STATUS_CHANGE_FAILED`: Cannot change user status - `SHOP_VERIFICATION_FAILED`: Shop verification operation failed - `ADMIN_OPERATION_FAILED`: Generic admin operation failure ### Validation (422) - `VALIDATION_ERROR`: Pydantic request validation failed - `INVALID_PRODUCT_DATA`: MarketplaceProduct data validation failed - `INVALID_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 error - `EXTERNAL_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_raise` suffix - 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.