410 lines
13 KiB
Markdown
410 lines
13 KiB
Markdown
# Exception Handling Guide
|
|
|
|
## Overview
|
|
|
|
The Wizamart 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
|
|
|
|
```
|
|
WizamartException (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(WizamartException)
|
|
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.
|