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>
410 lines
13 KiB
Markdown
410 lines
13 KiB
Markdown
# 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.
|