Exception handling enhancement

This commit is contained in:
2025-09-23 22:42:26 +02:00
parent b1a76cdb57
commit 98285aa8aa
35 changed files with 3283 additions and 1743 deletions

View File

@@ -16,14 +16,17 @@ from .base import (
BusinessLogicException,
ExternalServiceException,
RateLimitException,
ServiceUnavailableException,
)
from .auth import (
InvalidCredentialsException,
TokenExpiredException,
InvalidTokenException,
InsufficientPermissionsException,
UserNotActiveException,
AdminRequiredException,
UserAlreadyExistsException
)
from .product import (
@@ -31,6 +34,8 @@ from .product import (
ProductAlreadyExistsException,
InvalidProductDataException,
ProductValidationException,
InvalidGTINException,
ProductCSVImportException,
)
from .stock import (
@@ -38,6 +43,9 @@ from .stock import (
InsufficientStockException,
InvalidStockOperationException,
StockValidationException,
NegativeStockException,
InvalidQuantityException,
LocationNotFoundException
)
from .shop import (
@@ -47,6 +55,10 @@ from .shop import (
ShopNotVerifiedException,
UnauthorizedShopAccessException,
InvalidShopDataException,
ShopProductAlreadyExistsException,
ShopProductNotFoundException,
MaxShopsReachedException,
ShopValidationException,
)
from .marketplace import (
@@ -56,6 +68,11 @@ from .marketplace import (
InvalidImportDataException,
ImportJobCannotBeCancelledException,
ImportJobCannotBeDeletedException,
MarketplaceConnectionException,
MarketplaceDataParsingException,
ImportRateLimitException,
InvalidMarketplaceException,
ImportJobAlreadyProcessingException,
)
from .admin import (
@@ -63,6 +80,10 @@ from .admin import (
UserStatusChangeException,
ShopVerificationException,
AdminOperationException,
CannotModifyAdminException,
CannotModifySelfException,
InvalidAdminActionException,
BulkOperationException,
)
__all__ = [
@@ -80,21 +101,28 @@ __all__ = [
# Auth exceptions
"InvalidCredentialsException",
"TokenExpiredException",
"InvalidTokenException",
"InsufficientPermissionsException",
"UserNotActiveException",
"AdminRequiredException",
"UserAlreadyExistsException",
# Product exceptions
"ProductNotFoundException",
"ProductAlreadyExistsException",
"InvalidProductDataException",
"ProductValidationException",
"InvalidGTINException",
"ProductCSVImportException",
# Stock exceptions
"StockNotFoundException",
"InsufficientStockException",
"InvalidStockOperationException",
"StockValidationException",
"NegativeStockException",
"InvalidQuantityException",
"LocationNotFoundException",
# Shop exceptions
"ShopNotFoundException",
@@ -103,6 +131,10 @@ __all__ = [
"ShopNotVerifiedException",
"UnauthorizedShopAccessException",
"InvalidShopDataException",
"ShopProductAlreadyExistsException",
"ShopProductNotFoundException",
"MaxShopsReachedException",
"ShopValidationException",
# Marketplace exceptions
"MarketplaceImportException",
@@ -111,10 +143,19 @@ __all__ = [
"InvalidImportDataException",
"ImportJobCannotBeCancelledException",
"ImportJobCannotBeDeletedException",
"MarketplaceConnectionException",
"MarketplaceDataParsingException",
"ImportRateLimitException",
"InvalidMarketplaceException",
"ImportJobAlreadyProcessingException",
# Admin exceptions
"UserNotFoundException",
"UserStatusChangeException",
"ShopVerificationException",
"AdminOperationException",
"CannotModifyAdminException",
"CannotModifySelfException",
"InvalidAdminActionException",
"BulkOperationException",
]

View File

@@ -1,37 +1,30 @@
# app/exceptions/base.py
"""
Base exception classes for the LetzShop API.
Base exception classes for the LetzShop application.
Provides consistent error structure for frontend consumption with:
- Error codes for programmatic handling
- User-friendly messages
- HTTP status code mapping
- Additional context for debugging
This module provides classes and functions for:
- Base exception class with consistent error formatting
- Common exception types for different error categories
- Standardized error response structure
"""
from typing import Any, Dict, Optional
class LetzShopException(Exception):
"""
Base exception for all LetzShop API errors.
Provides consistent structure for frontend error handling.
"""
"""Base exception class for all LetzShop custom exceptions."""
def __init__(
self,
message: str,
error_code: str,
status_code: int = 500,
details: Optional[Dict[str, Any]] = None,
field: Optional[str] = None,
self,
message: str,
error_code: str,
status_code: int = 400,
details: Optional[Dict[str, Any]] = None,
):
self.message = message
self.error_code = error_code
self.status_code = status_code
self.details = details or {}
self.field = field
super().__init__(self.message)
def to_dict(self) -> Dict[str, Any]:
@@ -41,42 +34,44 @@ class LetzShopException(Exception):
"message": self.message,
"status_code": self.status_code,
}
if self.field:
result["field"] = self.field
if self.details:
result["details"] = self.details
return result
class ValidationException(LetzShopException):
"""Raised when input validation fails."""
"""Raised when request validation fails."""
def __init__(
self,
message: str = "Validation failed",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
self,
message: str,
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
validation_details = details or {}
if field:
validation_details["field"] = field
super().__init__(
message=message,
error_code="VALIDATION_ERROR",
status_code=400,
details=details,
field=field,
status_code=422,
details=validation_details,
)
class AuthenticationException(LetzShopException):
"""Raised when authentication fails."""
def __init__(
self,
message: str = "Authentication failed",
error_code: str = "AUTHENTICATION_FAILED",
details: Optional[Dict[str, Any]] = None,
self,
message: str = "Authentication failed",
error_code: str = "AUTHENTICATION_FAILED",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
@@ -87,13 +82,13 @@ class AuthenticationException(LetzShopException):
class AuthorizationException(LetzShopException):
"""Raised when user lacks required permissions."""
"""Raised when user lacks permission for an operation."""
def __init__(
self,
message: str = "Access denied",
error_code: str = "ACCESS_DENIED",
details: Optional[Dict[str, Any]] = None,
self,
message: str = "Access denied",
error_code: str = "AUTHORIZATION_FAILED",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
@@ -102,20 +97,18 @@ class AuthorizationException(LetzShopException):
details=details,
)
class ResourceNotFoundException(LetzShopException):
"""Raised when a requested resource is not found."""
def __init__(
self,
resource_type: str,
identifier: str,
message: Optional[str] = None,
error_code: Optional[str] = None,
self,
resource_type: str,
identifier: str,
message: Optional[str] = None,
error_code: Optional[str] = None,
):
if not message:
message = f"{resource_type} not found"
message = f"{resource_type} with identifier '{identifier}' not found"
if not error_code:
error_code = f"{resource_type.upper()}_NOT_FOUND"
@@ -123,18 +116,20 @@ class ResourceNotFoundException(LetzShopException):
message=message,
error_code=error_code,
status_code=404,
details={"resource_type": resource_type, "identifier": identifier},
details={
"resource_type": resource_type,
"identifier": identifier,
},
)
class ConflictException(LetzShopException):
"""Raised when a resource conflict occurs (e.g., duplicate)."""
"""Raised when a resource conflict occurs."""
def __init__(
self,
message: str,
error_code: str,
details: Optional[Dict[str, Any]] = None,
self,
message: str,
error_code: str = "RESOURCE_CONFLICT",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
@@ -143,43 +138,41 @@ class ConflictException(LetzShopException):
details=details,
)
class BusinessLogicException(LetzShopException):
"""Raised when business logic validation fails."""
"""Raised when business logic rules are violated."""
def __init__(
self,
message: str,
error_code: str,
details: Optional[Dict[str, Any]] = None,
self,
message: str,
error_code: str,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
error_code=error_code,
status_code=422,
status_code=400,
details=details,
)
class ExternalServiceException(LetzShopException):
"""Raised when external service calls fail."""
"""Raised when an external service fails."""
def __init__(
self,
service: str,
message: str = "External service unavailable",
error_code: str = "EXTERNAL_SERVICE_ERROR",
details: Optional[Dict[str, Any]] = None,
self,
message: str,
service_name: str,
error_code: str = "EXTERNAL_SERVICE_ERROR",
details: Optional[Dict[str, Any]] = None,
):
if not details:
details = {}
details["service"] = service
service_details = details or {}
service_details["service_name"] = service_name
super().__init__(
message=message,
error_code=error_code,
status_code=503,
details=details,
status_code=502,
details=service_details,
)
@@ -187,20 +180,32 @@ class RateLimitException(LetzShopException):
"""Raised when rate limit is exceeded."""
def __init__(
self,
message: str = "Rate limit exceeded",
retry_after: Optional[int] = None,
details: Optional[Dict[str, Any]] = None,
self,
message: str = "Rate limit exceeded",
retry_after: Optional[int] = None,
details: Optional[Dict[str, Any]] = None,
):
if not details:
details = {}
rate_limit_details = details or {}
if retry_after:
details["retry_after"] = retry_after
rate_limit_details["retry_after"] = retry_after
super().__init__(
message=message,
error_code="RATE_LIMIT_EXCEEDED",
status_code=429,
details=details,
details=rate_limit_details,
)
class ServiceUnavailableException(LetzShopException):
"""Raised when service is unavailable."""
def __init__(self, message: str = "Service temporarily unavailable"):
super().__init__(
message=message,
error_code="SERVICE_UNAVAILABLE",
status_code=503,
)
# Note: Domain-specific exceptions like ShopNotFoundException, UserNotFoundException, etc.
# are defined in their respective domain modules (shop.py, admin.py, etc.)
# to keep domain-specific logic separate from base exceptions.

View File

@@ -3,113 +3,24 @@
Exception handler for FastAPI application.
Provides consistent error responses and logging for all custom exceptions.
This module provides classes and functions for:
- Unified exception handling for all application exceptions
- Consistent error response formatting
- Comprehensive logging with structured data
"""
import logging
from typing import Union
from fastapi import Request, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from .base import LetzShopException
logger = logging.getLogger(__name__)
class ExceptionHandlerMiddleware(BaseHTTPMiddleware):
"""Middleware to handle custom exceptions and convert them to JSON responses."""
async def dispatch(self, request: Request, call_next):
try:
response = await call_next(request)
return response
except LetzShopException as exc:
return await self.handle_custom_exception(request, exc)
except HTTPException as exc:
return await self.handle_http_exception(request, exc)
except Exception as exc:
return await self.handle_generic_exception(request, exc)
async def handle_custom_exception(
self,
request: Request,
exc: LetzShopException
) -> JSONResponse:
"""Handle custom LetzShop exceptions."""
# Log the exception
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,
}
)
return JSONResponse(
status_code=exc.status_code,
content=exc.to_dict()
)
async def handle_http_exception(
self,
request: Request,
exc: HTTPException
) -> JSONResponse:
"""Handle FastAPI HTTPExceptions."""
logger.error(
f"HTTP exception in {request.method} {request.url}: "
f"{exc.status_code} - {exc.detail}",
extra={
"status_code": exc.status_code,
"detail": exc.detail,
"url": str(request.url),
"method": request.method,
}
)
return JSONResponse(
status_code=exc.status_code,
content={
"error_code": f"HTTP_{exc.status_code}",
"message": exc.detail,
"status_code": exc.status_code,
}
)
async def handle_generic_exception(
self,
request: Request,
exc: Exception
) -> JSONResponse:
"""Handle unexpected exceptions."""
logger.error(
f"Unexpected exception in {request.method} {request.url}: {str(exc)}",
exc_info=True,
extra={
"url": str(request.url),
"method": request.method,
"exception_type": type(exc).__name__,
}
)
return JSONResponse(
status_code=500,
content={
"error_code": "INTERNAL_SERVER_ERROR",
"message": "Internal server error",
"status_code": 500,
}
)
def setup_exception_handlers(app):
"""Setup exception handlers for the FastAPI app."""
@@ -126,6 +37,7 @@ def setup_exception_handlers(app):
"details": exc.details,
"url": str(request.url),
"method": request.method,
"exception_type": type(exc).__name__,
}
)
@@ -146,6 +58,7 @@ def setup_exception_handlers(app):
"detail": exc.detail,
"url": str(request.url),
"method": request.method,
"exception_type": "HTTPException",
}
)
@@ -158,6 +71,32 @@ def setup_exception_handlers(app):
}
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle Pydantic validation errors with consistent format."""
logger.error(
f"Validation error in {request.method} {request.url}: {exc.errors()}",
extra={
"validation_errors": exc.errors(),
"url": str(request.url),
"method": request.method,
"exception_type": "RequestValidationError",
}
)
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: Request, exc: Exception):
"""Handle unexpected exceptions."""
@@ -204,4 +143,4 @@ def raise_auth_error(message: str = "Authentication failed") -> None:
def raise_permission_error(message: str = "Access denied") -> None:
"""Convenience function to raise AuthorizationException."""
from .base import AuthorizationException
raise AuthorizationException(message)
raise AuthorizationException(message)