Exception handling enhancement
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user