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