Files
orion/docs/development/exception-handling.md

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

  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.