diff --git a/docs/development/exception-handling.md b/docs/development/exception-handling.md new file mode 100644 index 00000000..2628152d --- /dev/null +++ b/docs/development/exception-handling.md @@ -0,0 +1,491 @@ +# Exception Handling Guide + +## Overview + +The LetzShop API uses a structured custom exception system to provide consistent, meaningful error responses across all endpoints. This guide covers how to work with exceptions, maintain the system, and extend it for new features. + +## Architecture + +### Exception Hierarchy + +``` +LetzShopException (Base) +├── ValidationException (400) +├── AuthenticationException (401) +├── AuthorizationException (403) +├── ResourceNotFoundException (404) +├── ConflictException (409) +├── BusinessLogicException (422) +├── RateLimitException (429) +└── ExternalServiceException (503) +``` + +### File Structure + +``` +app/exceptions/ +├── __init__.py # All exception exports +├── base.py # Base exception classes +├── handler.py # FastAPI exception handlers +├── auth.py # Authentication exceptions +├── admin.py # Admin operation exceptions +├── marketplace.py # Import/marketplace exceptions +├── product.py # Product management exceptions +├── shop.py # Shop management exceptions +└── stock.py # Stock management exceptions +``` + +## Core Concepts + +### Exception Response Format + +All custom exceptions return a consistent JSON structure: + +```json +{ + "error_code": "PRODUCT_NOT_FOUND", + "message": "Product with ID 'ABC123' not found", + "status_code": 404, + "field": "product_id", + "details": { + "resource_type": "Product", + "identifier": "ABC123" + } +} +``` + +### Properties + +- **error_code**: Machine-readable identifier for programmatic handling +- **message**: Human-readable description for users +- **status_code**: HTTP status code +- **field**: Optional field name for validation errors +- **details**: Additional context data + +## Working with Exceptions + +### Basic Usage + +```python +from app.exceptions import ProductNotFoundException, ProductAlreadyExistsException + +# Simple not found +raise ProductNotFoundException("ABC123") + +# Conflict with existing resource +raise ProductAlreadyExistsException("ABC123") +``` + +### Validation Exceptions + +```python +from app.exceptions import ValidationException, InvalidProductDataException + +# Generic validation error +raise ValidationException("Invalid data format", field="price") + +# Specific product validation +raise InvalidProductDataException( + "Price must be positive", + field="price", + details={"provided_price": -10} +) +``` + +### Business Logic Exceptions + +```python +from app.exceptions import InsufficientStockException + +# Detailed business logic error +raise InsufficientStockException( + gtin="1234567890", + location="warehouse_a", + requested=100, + available=50 +) +``` + +## Endpoint Implementation Patterns + +### Service Layer Integration + +Map service layer `ValueError` exceptions to specific custom exceptions: + +```python +@router.post("/product") +def create_product(product: ProductCreate, db: Session = Depends(get_db)): + try: + result = product_service.create_product(db, product) + return result + except ValueError as e: + error_msg = str(e).lower() + if "already exists" in error_msg: + raise ProductAlreadyExistsException(product.product_id) + elif "invalid gtin" in error_msg: + raise InvalidProductDataException(str(e), field="gtin") + else: + raise ValidationException(str(e)) + except Exception as e: + logger.error(f"Unexpected error: {str(e)}") + raise ValidationException("Failed to create product") +``` + +### Error Message Parsing + +Use regex patterns to extract context from service errors: + +```python +import re + +try: + service.remove_stock(gtin, location, quantity) +except ValueError as e: + error_msg = str(e).lower() + if "insufficient stock" in error_msg: + # Extract quantities from error message + available_match = re.search(r'available[:\s]+(\d+)', error_msg) + requested_match = re.search(r'requested[:\s]+(\d+)', error_msg) + + available = int(available_match.group(1)) if available_match else 0 + requested = int(requested_match.group(1)) if requested_match else quantity + + raise InsufficientStockException(gtin, location, requested, available) +``` + +### Exception Re-raising + +Always re-raise custom exceptions to maintain their specific type: + +```python +try: + shop = get_user_shop(shop_code, current_user, db) + # ... operations +except UnauthorizedShopAccessException: + raise # Re-raise without modification +except ValueError as e: + # Handle other ValueError cases + raise InvalidShopDataException(str(e)) +``` + +## Creating New Exceptions + +### Step 1: Define the Exception + +Create new 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 + +Add new exceptions to the exports: + +```python +# app/exceptions/__init__.py +from .payment import ( + PaymentNotFoundException, + InsufficientFundsException, +) + +__all__ = [ + # ... existing exports + "PaymentNotFoundException", + "InsufficientFundsException", +] +``` + +### Step 3: Use in Endpoints + +Import and use the new exceptions: + +```python +from app.exceptions import PaymentNotFoundException, InsufficientFundsException + +@router.post("/payments/{payment_id}/process") +def process_payment(payment_id: str, db: Session = Depends(get_db)): + try: + result = payment_service.process_payment(db, payment_id) + return result + except ValueError as e: + if "not found" in str(e).lower(): + raise PaymentNotFoundException(payment_id) + elif "insufficient funds" in str(e).lower(): + # Extract amounts from error message + raise InsufficientFundsException(100.0, 50.0, "account_123") + else: + raise ValidationException(str(e)) +``` + +## Testing Exceptions + +### Unit Tests + +Test exception raising and properties: + +```python +import pytest +from app.exceptions import ProductNotFoundException, InsufficientStockException + +def test_product_not_found_exception(): + with pytest.raises(ProductNotFoundException) as exc_info: + raise ProductNotFoundException("ABC123") + + exception = exc_info.value + assert exception.error_code == "PRODUCT_NOT_FOUND" + assert exception.status_code == 404 + assert "ABC123" in exception.message + assert exception.details["identifier"] == "ABC123" + +def test_insufficient_stock_exception(): + exc = InsufficientStockException( + gtin="1234567890", + location="warehouse", + requested=100, + available=50 + ) + + assert exc.error_code == "INSUFFICIENT_STOCK" + assert exc.status_code == 422 + assert exc.details["requested_quantity"] == 100 + assert exc.details["available_quantity"] == 50 +``` + +### Integration Tests + +Test endpoint exception handling: + +```python +def test_create_duplicate_product(client, auth_headers): + # Create initial product + product_data = {"product_id": "TEST123", "name": "Test Product"} + client.post("/api/v1/product", json=product_data, headers=auth_headers) + + # Attempt to create duplicate + response = client.post("/api/v1/product", json=product_data, headers=auth_headers) + + assert response.status_code == 409 + data = response.json() + assert data["error_code"] == "PRODUCT_ALREADY_EXISTS" + assert "TEST123" in data["message"] + assert data["details"]["product_id"] == "TEST123" +``` + +## Best Practices + +### Do's + +- **Use specific exceptions** rather than generic ValidationException +- **Include relevant context** in exception details +- **Map service errors** to appropriate custom exceptions +- **Re-raise custom exceptions** without modification +- **Log errors** before raising exceptions +- **Extract data** from error messages when possible + +### Don'ts + +- **Don't use HTTPException** in endpoints (use custom exceptions) +- **Don't lose exception context** when mapping errors +- **Don't include sensitive data** in error messages +- **Don't create exceptions** for every possible error case +- **Don't ignore the exception hierarchy** when creating new exceptions + +### Error Message Guidelines + +```python +# Good: Specific, actionable message +raise InsufficientStockException( + gtin="1234567890", + location="warehouse_a", + requested=100, + available=50 +) + +# Bad: Generic, unhelpful message +raise ValidationException("Operation failed") + +# Good: Include identifier context +raise ProductNotFoundException("ABC123") + +# Bad: No context about what wasn't found +raise ValidationException("Not found") +``` + +## Debugging and Logging + +### Exception Logging + +The exception handler automatically logs all exceptions: + +```python +# Logs include structured data +logger.error( + f"Custom exception in {request.method} {request.url}: " + f"{exc.error_code} - {exc.message}", + extra={ + "error_code": exc.error_code, + "status_code": exc.status_code, + "details": exc.details, + "url": str(request.url), + "method": request.method, + } +) +``` + +### Adding Debug Information + +Include additional context in exception details: + +```python +try: + result = complex_operation() +except ValueError as e: + raise BusinessLogicException( + message="Complex operation failed", + error_code="COMPLEX_OPERATION_FAILED", + details={ + "original_error": str(e), + "operation_params": {"param1": value1, "param2": value2}, + "timestamp": datetime.utcnow().isoformat(), + } + ) +``` + +## Frontend Integration + +### Error Code Handling + +Frontend can handle errors programmatically: + +```javascript +// JavaScript example +try { + const response = await api.createProduct(productData); +} catch (error) { + switch (error.error_code) { + case 'PRODUCT_ALREADY_EXISTS': + showError('Product ID already exists. Please choose a different ID.'); + break; + case 'INVALID_PRODUCT_DATA': + showFieldError(error.field, error.message); + break; + default: + showGenericError(error.message); + } +} +``` + +### User-Friendly Messages + +Map error codes to user-friendly messages: + +```javascript +const ERROR_MESSAGES = { + 'PRODUCT_NOT_FOUND': 'The requested product could not be found.', + 'INSUFFICIENT_STOCK': 'Not enough stock available for this operation.', + 'UNAUTHORIZED_SHOP_ACCESS': 'You do not have permission to access this shop.', + 'IMPORT_JOB_CANNOT_BE_CANCELLED': 'This import job cannot be cancelled at this time.', +}; +``` + +## Monitoring and Metrics + +### Error Tracking + +Track exception frequency and patterns: + +```python +# Add to exception handler +from prometheus_client import Counter + +exception_counter = Counter( + 'api_exceptions_total', + 'Total number of API exceptions', + ['error_code', 'endpoint', 'method'] +) + +# In exception handler +exception_counter.labels( + error_code=exc.error_code, + endpoint=request.url.path, + method=request.method +).inc() +``` + +### Health Monitoring + +Monitor exception rates for system health: + +- Track error rates per endpoint +- Alert on unusual exception patterns +- Monitor specific business logic errors +- Track client vs server error ratios + +## Migration Guide + +### From HTTPException to Custom Exceptions + +Replace existing HTTPException usage: + +```python +# Before +from fastapi import HTTPException + +if not product: + raise HTTPException(status_code=404, detail="Product not found") + +# After +from app.exceptions import ProductNotFoundException + +if not product: + raise ProductNotFoundException(product_id) +``` + +### Updating Service Layer + +Enhance service layer error messages: + +```python +# Before +def get_product(db, product_id): + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise ValueError("Not found") + return product + +# After +def get_product(db, product_id): + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise ValueError(f"Product not found: {product_id}") + return product +``` + +This exception system provides a robust foundation for error handling that scales with your application while maintaining consistency for both developers and frontend clients.