Files
orion/docs/development/exception-handling.md
Samir Boulahtit e9253fbd84 refactor: rename Wizamart to Orion across entire codebase
Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart
with Orion/orion/ORION across 184 files. This includes database
identifiers, email addresses, domain references, R2 bucket names,
DNS prefixes, encryption salt, Celery app name, config defaults,
Docker configs, CI configs, documentation, seed data, and templates.

Renames homepage-wizamart.html template to homepage-orion.html.
Fixes duplicate file_pattern key in api.yaml architecture rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:46:56 +01:00

13 KiB

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:

{
  "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(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:

# 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_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:

# 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.