Add custom exceptions handling
This commit is contained in:
120
app/exceptions/__init__.py
Normal file
120
app/exceptions/__init__.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# app/exceptions/__init__.py
|
||||
"""
|
||||
Custom exception classes for the LetzShop API.
|
||||
|
||||
This module provides frontend-friendly exceptions with consistent error codes,
|
||||
messages, and HTTP status mappings.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
LetzShopException,
|
||||
ValidationException,
|
||||
AuthenticationException,
|
||||
AuthorizationException,
|
||||
ResourceNotFoundException,
|
||||
ConflictException,
|
||||
BusinessLogicException,
|
||||
ExternalServiceException,
|
||||
RateLimitException,
|
||||
)
|
||||
|
||||
from .auth import (
|
||||
InvalidCredentialsException,
|
||||
TokenExpiredException,
|
||||
InsufficientPermissionsException,
|
||||
UserNotActiveException,
|
||||
AdminRequiredException,
|
||||
)
|
||||
|
||||
from .product import (
|
||||
ProductNotFoundException,
|
||||
ProductAlreadyExistsException,
|
||||
InvalidProductDataException,
|
||||
ProductValidationException,
|
||||
)
|
||||
|
||||
from .stock import (
|
||||
StockNotFoundException,
|
||||
InsufficientStockException,
|
||||
InvalidStockOperationException,
|
||||
StockValidationException,
|
||||
)
|
||||
|
||||
from .shop import (
|
||||
ShopNotFoundException,
|
||||
ShopAlreadyExistsException,
|
||||
ShopNotActiveException,
|
||||
ShopNotVerifiedException,
|
||||
UnauthorizedShopAccessException,
|
||||
InvalidShopDataException,
|
||||
)
|
||||
|
||||
from .marketplace import (
|
||||
MarketplaceImportException,
|
||||
ImportJobNotFoundException,
|
||||
ImportJobNotOwnedException,
|
||||
InvalidImportDataException,
|
||||
ImportJobCannotBeCancelledException,
|
||||
ImportJobCannotBeDeletedException,
|
||||
)
|
||||
|
||||
from .admin import (
|
||||
UserNotFoundException,
|
||||
UserStatusChangeException,
|
||||
ShopVerificationException,
|
||||
AdminOperationException,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base exceptions
|
||||
"LetzShopException",
|
||||
"ValidationException",
|
||||
"AuthenticationException",
|
||||
"AuthorizationException",
|
||||
"ResourceNotFoundException",
|
||||
"ConflictException",
|
||||
"BusinessLogicException",
|
||||
"ExternalServiceException",
|
||||
"RateLimitException",
|
||||
|
||||
# Auth exceptions
|
||||
"InvalidCredentialsException",
|
||||
"TokenExpiredException",
|
||||
"InsufficientPermissionsException",
|
||||
"UserNotActiveException",
|
||||
"AdminRequiredException",
|
||||
|
||||
# Product exceptions
|
||||
"ProductNotFoundException",
|
||||
"ProductAlreadyExistsException",
|
||||
"InvalidProductDataException",
|
||||
"ProductValidationException",
|
||||
|
||||
# Stock exceptions
|
||||
"StockNotFoundException",
|
||||
"InsufficientStockException",
|
||||
"InvalidStockOperationException",
|
||||
"StockValidationException",
|
||||
|
||||
# Shop exceptions
|
||||
"ShopNotFoundException",
|
||||
"ShopAlreadyExistsException",
|
||||
"ShopNotActiveException",
|
||||
"ShopNotVerifiedException",
|
||||
"UnauthorizedShopAccessException",
|
||||
"InvalidShopDataException",
|
||||
|
||||
# Marketplace exceptions
|
||||
"MarketplaceImportException",
|
||||
"ImportJobNotFoundException",
|
||||
"ImportJobNotOwnedException",
|
||||
"InvalidImportDataException",
|
||||
"ImportJobCannotBeCancelledException",
|
||||
"ImportJobCannotBeDeletedException",
|
||||
|
||||
# Admin exceptions
|
||||
"UserNotFoundException",
|
||||
"UserStatusChangeException",
|
||||
"ShopVerificationException",
|
||||
"AdminOperationException",
|
||||
]
|
||||
191
app/exceptions/admin.py
Normal file
191
app/exceptions/admin.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# app/exceptions/admin.py
|
||||
"""
|
||||
Admin operations specific exceptions.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import (
|
||||
ResourceNotFoundException,
|
||||
BusinessLogicException,
|
||||
AuthorizationException,
|
||||
ValidationException
|
||||
)
|
||||
|
||||
|
||||
class UserNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when user is not found in admin operations."""
|
||||
|
||||
def __init__(self, user_identifier: str, identifier_type: str = "ID"):
|
||||
if identifier_type.lower() == "username":
|
||||
message = f"User with username '{user_identifier}' not found"
|
||||
elif identifier_type.lower() == "email":
|
||||
message = f"User with email '{user_identifier}' not found"
|
||||
else:
|
||||
message = f"User with ID '{user_identifier}' not found"
|
||||
|
||||
super().__init__(
|
||||
resource_type="User",
|
||||
identifier=user_identifier,
|
||||
message=message,
|
||||
error_code="USER_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class UserStatusChangeException(BusinessLogicException):
|
||||
"""Raised when user status cannot be changed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: int,
|
||||
current_status: str,
|
||||
attempted_action: str,
|
||||
reason: Optional[str] = None,
|
||||
):
|
||||
message = f"Cannot {attempted_action} user {user_id} (current status: {current_status})"
|
||||
if reason:
|
||||
message += f": {reason}"
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="USER_STATUS_CHANGE_FAILED",
|
||||
details={
|
||||
"user_id": user_id,
|
||||
"current_status": current_status,
|
||||
"attempted_action": attempted_action,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ShopVerificationException(BusinessLogicException):
|
||||
"""Raised when shop verification fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shop_id: int,
|
||||
reason: str,
|
||||
current_verification_status: Optional[bool] = None,
|
||||
):
|
||||
details = {
|
||||
"shop_id": shop_id,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
if current_verification_status is not None:
|
||||
details["current_verification_status"] = current_verification_status
|
||||
|
||||
super().__init__(
|
||||
message=f"Shop verification failed for shop {shop_id}: {reason}",
|
||||
error_code="SHOP_VERIFICATION_FAILED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class AdminOperationException(BusinessLogicException):
|
||||
"""Raised when admin operation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
reason: str,
|
||||
target_type: Optional[str] = None,
|
||||
target_id: Optional[str] = None,
|
||||
):
|
||||
message = f"Admin operation '{operation}' failed: {reason}"
|
||||
|
||||
details = {
|
||||
"operation": operation,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
if target_type:
|
||||
details["target_type"] = target_type
|
||||
if target_id:
|
||||
details["target_id"] = target_id
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="ADMIN_OPERATION_FAILED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class CannotModifyAdminException(AuthorizationException):
|
||||
"""Raised when trying to modify another admin user."""
|
||||
|
||||
def __init__(self, target_user_id: int, admin_user_id: int):
|
||||
super().__init__(
|
||||
message=f"Cannot modify admin user {target_user_id}",
|
||||
error_code="CANNOT_MODIFY_ADMIN",
|
||||
details={
|
||||
"target_user_id": target_user_id,
|
||||
"admin_user_id": admin_user_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class CannotModifySelfException(BusinessLogicException):
|
||||
"""Raised when admin tries to modify their own status."""
|
||||
|
||||
def __init__(self, user_id: int, operation: str):
|
||||
super().__init__(
|
||||
message=f"Cannot perform '{operation}' on your own account",
|
||||
error_code="CANNOT_MODIFY_SELF",
|
||||
details={
|
||||
"user_id": user_id,
|
||||
"operation": operation,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvalidAdminActionException(ValidationException):
|
||||
"""Raised when admin action is invalid."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action: str,
|
||||
reason: str,
|
||||
valid_actions: Optional[list] = None,
|
||||
):
|
||||
details = {
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
if valid_actions:
|
||||
details["valid_actions"] = valid_actions
|
||||
|
||||
super().__init__(
|
||||
message=f"Invalid admin action '{action}': {reason}",
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "INVALID_ADMIN_ACTION"
|
||||
|
||||
|
||||
class BulkOperationException(BusinessLogicException):
|
||||
"""Raised when bulk admin operation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
total_items: int,
|
||||
failed_items: int,
|
||||
errors: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
message = f"Bulk {operation} completed with errors: {failed_items}/{total_items} failed"
|
||||
|
||||
details = {
|
||||
"operation": operation,
|
||||
"total_items": total_items,
|
||||
"failed_items": failed_items,
|
||||
"success_items": total_items - failed_items,
|
||||
}
|
||||
|
||||
if errors:
|
||||
details["errors"] = errors
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="BULK_OPERATION_PARTIAL_FAILURE",
|
||||
details=details,
|
||||
)
|
||||
95
app/exceptions/auth.py
Normal file
95
app/exceptions/auth.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# app/exceptions/auth.py
|
||||
"""
|
||||
Authentication and authorization specific exceptions.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import AuthenticationException, AuthorizationException
|
||||
|
||||
|
||||
class InvalidCredentialsException(AuthenticationException):
|
||||
"""Raised when login credentials are invalid."""
|
||||
|
||||
def __init__(self, message: str = "Invalid username or password"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="INVALID_CREDENTIALS",
|
||||
)
|
||||
|
||||
|
||||
class TokenExpiredException(AuthenticationException):
|
||||
"""Raised when JWT token has expired."""
|
||||
|
||||
def __init__(self, message: str = "Token has expired"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="TOKEN_EXPIRED",
|
||||
)
|
||||
|
||||
|
||||
class InvalidTokenException(AuthenticationException):
|
||||
"""Raised when JWT token is invalid or malformed."""
|
||||
|
||||
def __init__(self, message: str = "Invalid token"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="INVALID_TOKEN",
|
||||
)
|
||||
|
||||
|
||||
class InsufficientPermissionsException(AuthorizationException):
|
||||
"""Raised when user lacks required permissions for an action."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Insufficient permissions for this action",
|
||||
required_permission: Optional[str] = None,
|
||||
):
|
||||
details = {}
|
||||
if required_permission:
|
||||
details["required_permission"] = required_permission
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="INSUFFICIENT_PERMISSIONS",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class UserNotActiveException(AuthorizationException):
|
||||
"""Raised when user account is not active."""
|
||||
|
||||
def __init__(self, message: str = "User account is not active"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="USER_NOT_ACTIVE",
|
||||
)
|
||||
|
||||
|
||||
class AdminRequiredException(AuthorizationException):
|
||||
"""Raised when admin privileges are required."""
|
||||
|
||||
def __init__(self, message: str = "Admin privileges required"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="ADMIN_REQUIRED",
|
||||
)
|
||||
|
||||
|
||||
class UserAlreadyExistsException(AuthenticationException):
|
||||
"""Raised when trying to register with existing username/email."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "User already exists",
|
||||
field: Optional[str] = None,
|
||||
):
|
||||
details = {}
|
||||
if field:
|
||||
details["field"] = field
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="USER_ALREADY_EXISTS",
|
||||
details=details,
|
||||
)
|
||||
206
app/exceptions/base.py
Normal file
206
app/exceptions/base.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# app/exceptions/base.py
|
||||
"""
|
||||
Base exception classes for the LetzShop API.
|
||||
|
||||
Provides consistent error structure for frontend consumption with:
|
||||
- Error codes for programmatic handling
|
||||
- User-friendly messages
|
||||
- HTTP status code mapping
|
||||
- Additional context for debugging
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class LetzShopException(Exception):
|
||||
"""
|
||||
Base exception for all LetzShop API errors.
|
||||
|
||||
Provides consistent structure for frontend error handling.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
error_code: str,
|
||||
status_code: int = 500,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
field: Optional[str] = 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]:
|
||||
"""Convert exception to dictionary for JSON response."""
|
||||
result = {
|
||||
"error_code": self.error_code,
|
||||
"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."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Validation failed",
|
||||
field: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="VALIDATION_ERROR",
|
||||
status_code=400,
|
||||
details=details,
|
||||
field=field,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
status_code=401,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationException(LetzShopException):
|
||||
"""Raised when user lacks required permissions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Access denied",
|
||||
error_code: str = "ACCESS_DENIED",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
status_code=403,
|
||||
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,
|
||||
):
|
||||
if not message:
|
||||
message = f"{resource_type} not found"
|
||||
|
||||
if not error_code:
|
||||
error_code = f"{resource_type.upper()}_NOT_FOUND"
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
status_code=404,
|
||||
details={"resource_type": resource_type, "identifier": identifier},
|
||||
)
|
||||
|
||||
|
||||
class ConflictException(LetzShopException):
|
||||
"""Raised when a resource conflict occurs (e.g., duplicate)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
error_code: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
status_code=409,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class BusinessLogicException(LetzShopException):
|
||||
"""Raised when business logic validation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
error_code: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
status_code=422,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class ExternalServiceException(LetzShopException):
|
||||
"""Raised when external service calls fail."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: str,
|
||||
message: str = "External service unavailable",
|
||||
error_code: str = "EXTERNAL_SERVICE_ERROR",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not details:
|
||||
details = {}
|
||||
details["service"] = service
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
status_code=503,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
):
|
||||
if not details:
|
||||
details = {}
|
||||
|
||||
if retry_after:
|
||||
details["retry_after"] = retry_after
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="RATE_LIMIT_EXCEEDED",
|
||||
status_code=429,
|
||||
details=details,
|
||||
)
|
||||
207
app/exceptions/handler.py
Normal file
207
app/exceptions/handler.py
Normal file
@@ -0,0 +1,207 @@
|
||||
# app/exceptions/handler.py
|
||||
"""
|
||||
Exception handler for FastAPI application.
|
||||
|
||||
Provides consistent error responses and logging for all custom exceptions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Union
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
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."""
|
||||
|
||||
@app.exception_handler(LetzShopException)
|
||||
async def custom_exception_handler(request: Request, exc: LetzShopException):
|
||||
"""Handle custom LetzShop exceptions."""
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""Handle FastAPI HTTPExceptions with consistent format."""
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_exception_handler(request: Request, exc: Exception):
|
||||
"""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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Utility functions for common exception scenarios
|
||||
def raise_not_found(resource_type: str, identifier: str) -> None:
|
||||
"""Convenience function to raise ResourceNotFoundException."""
|
||||
from .base import ResourceNotFoundException
|
||||
raise ResourceNotFoundException(resource_type, identifier)
|
||||
|
||||
|
||||
def raise_validation_error(message: str, field: str = None, details: dict = None) -> None:
|
||||
"""Convenience function to raise ValidationException."""
|
||||
from .base import ValidationException
|
||||
raise ValidationException(message, field, details)
|
||||
|
||||
|
||||
def raise_auth_error(message: str = "Authentication failed") -> None:
|
||||
"""Convenience function to raise AuthenticationException."""
|
||||
from .base import AuthenticationException
|
||||
raise AuthenticationException(message)
|
||||
|
||||
|
||||
def raise_permission_error(message: str = "Access denied") -> None:
|
||||
"""Convenience function to raise AuthorizationException."""
|
||||
from .base import AuthorizationException
|
||||
raise AuthorizationException(message)
|
||||
200
app/exceptions/marketplace.py
Normal file
200
app/exceptions/marketplace.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# app/exceptions/marketplace.py
|
||||
"""
|
||||
Marketplace import specific exceptions.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import (
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
BusinessLogicException,
|
||||
AuthorizationException,
|
||||
ExternalServiceException
|
||||
)
|
||||
|
||||
|
||||
class MarketplaceImportException(BusinessLogicException):
|
||||
"""Base exception for marketplace import operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
error_code: str = "MARKETPLACE_IMPORT_ERROR",
|
||||
marketplace: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not details:
|
||||
details = {}
|
||||
|
||||
if marketplace:
|
||||
details["marketplace"] = marketplace
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class ImportJobNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when import job is not found."""
|
||||
|
||||
def __init__(self, job_id: int):
|
||||
super().__init__(
|
||||
resource_type="ImportJob",
|
||||
identifier=str(job_id),
|
||||
message=f"Import job with ID '{job_id}' not found",
|
||||
error_code="IMPORT_JOB_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class ImportJobNotOwnedException(AuthorizationException):
|
||||
"""Raised when user tries to access import job they don't own."""
|
||||
|
||||
def __init__(self, job_id: int, user_id: Optional[int] = None):
|
||||
details = {"job_id": job_id}
|
||||
if user_id:
|
||||
details["user_id"] = user_id
|
||||
|
||||
super().__init__(
|
||||
message=f"Unauthorized access to import job '{job_id}'",
|
||||
error_code="IMPORT_JOB_NOT_OWNED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class InvalidImportDataException(ValidationException):
|
||||
"""Raised when import data is invalid."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Invalid import data",
|
||||
field: Optional[str] = None,
|
||||
row_number: Optional[int] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not details:
|
||||
details = {}
|
||||
|
||||
if row_number:
|
||||
details["row_number"] = row_number
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "INVALID_IMPORT_DATA"
|
||||
|
||||
|
||||
class ImportJobCannotBeCancelledException(BusinessLogicException):
|
||||
"""Raised when trying to cancel job that cannot be cancelled."""
|
||||
|
||||
def __init__(self, job_id: int, current_status: str):
|
||||
super().__init__(
|
||||
message=f"Import job '{job_id}' cannot be cancelled (current status: {current_status})",
|
||||
error_code="IMPORT_JOB_CANNOT_BE_CANCELLED",
|
||||
details={
|
||||
"job_id": job_id,
|
||||
"current_status": current_status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ImportJobCannotBeDeletedException(BusinessLogicException):
|
||||
"""Raised when trying to delete job that cannot be deleted."""
|
||||
|
||||
def __init__(self, job_id: int, current_status: str):
|
||||
super().__init__(
|
||||
message=f"Import job '{job_id}' cannot be deleted (current status: {current_status})",
|
||||
error_code="IMPORT_JOB_CANNOT_BE_DELETED",
|
||||
details={
|
||||
"job_id": job_id,
|
||||
"current_status": current_status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class MarketplaceConnectionException(ExternalServiceException):
|
||||
"""Raised when marketplace connection fails."""
|
||||
|
||||
def __init__(self, marketplace: str, message: str = "Failed to connect to marketplace"):
|
||||
super().__init__(
|
||||
service=marketplace,
|
||||
message=f"{message}: {marketplace}",
|
||||
error_code="MARKETPLACE_CONNECTION_FAILED",
|
||||
)
|
||||
|
||||
|
||||
class MarketplaceDataParsingException(ValidationException):
|
||||
"""Raised when marketplace data cannot be parsed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
marketplace: str,
|
||||
message: str = "Failed to parse marketplace data",
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not details:
|
||||
details = {}
|
||||
details["marketplace"] = marketplace
|
||||
|
||||
super().__init__(
|
||||
message=f"{message} from {marketplace}",
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "MARKETPLACE_DATA_PARSING_FAILED"
|
||||
|
||||
|
||||
class ImportRateLimitException(BusinessLogicException):
|
||||
"""Raised when import rate limit is exceeded."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_imports: int,
|
||||
time_window: str,
|
||||
retry_after: Optional[int] = None,
|
||||
):
|
||||
details = {
|
||||
"max_imports": max_imports,
|
||||
"time_window": time_window,
|
||||
}
|
||||
|
||||
if retry_after:
|
||||
details["retry_after"] = retry_after
|
||||
|
||||
super().__init__(
|
||||
message=f"Import rate limit exceeded: {max_imports} imports per {time_window}",
|
||||
error_code="IMPORT_RATE_LIMIT_EXCEEDED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class InvalidMarketplaceException(ValidationException):
|
||||
"""Raised when marketplace is not supported."""
|
||||
|
||||
def __init__(self, marketplace: str, supported_marketplaces: Optional[list] = None):
|
||||
details = {"marketplace": marketplace}
|
||||
if supported_marketplaces:
|
||||
details["supported_marketplaces"] = supported_marketplaces
|
||||
|
||||
super().__init__(
|
||||
message=f"Unsupported marketplace: {marketplace}",
|
||||
field="marketplace",
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "INVALID_MARKETPLACE"
|
||||
|
||||
|
||||
class ImportJobAlreadyProcessingException(BusinessLogicException):
|
||||
"""Raised when trying to start import while another is already processing."""
|
||||
|
||||
def __init__(self, shop_code: str, existing_job_id: int):
|
||||
super().__init__(
|
||||
message=f"Import already in progress for shop '{shop_code}'",
|
||||
error_code="IMPORT_JOB_ALREADY_PROCESSING",
|
||||
details={
|
||||
"shop_code": shop_code,
|
||||
"existing_job_id": existing_job_id,
|
||||
},
|
||||
)
|
||||
102
app/exceptions/product.py
Normal file
102
app/exceptions/product.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# app/exceptions/product.py
|
||||
"""
|
||||
Product management specific exceptions.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ResourceNotFoundException, ConflictException, ValidationException, BusinessLogicException
|
||||
|
||||
|
||||
class ProductNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a product is not found."""
|
||||
|
||||
def __init__(self, product_id: str):
|
||||
super().__init__(
|
||||
resource_type="Product",
|
||||
identifier=product_id,
|
||||
message=f"Product with ID '{product_id}' not found",
|
||||
error_code="PRODUCT_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class ProductAlreadyExistsException(ConflictException):
|
||||
"""Raised when trying to create a product that already exists."""
|
||||
|
||||
def __init__(self, product_id: str):
|
||||
super().__init__(
|
||||
message=f"Product with ID '{product_id}' already exists",
|
||||
error_code="PRODUCT_ALREADY_EXISTS",
|
||||
details={"product_id": product_id},
|
||||
)
|
||||
|
||||
|
||||
class InvalidProductDataException(ValidationException):
|
||||
"""Raised when product data is invalid."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Invalid product data",
|
||||
field: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "INVALID_PRODUCT_DATA"
|
||||
|
||||
|
||||
class ProductValidationException(ValidationException):
|
||||
"""Raised when product validation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
field: Optional[str] = None,
|
||||
validation_errors: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
details = {}
|
||||
if validation_errors:
|
||||
details["validation_errors"] = validation_errors
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "PRODUCT_VALIDATION_FAILED"
|
||||
|
||||
|
||||
class InvalidGTINException(ValidationException):
|
||||
"""Raised when GTIN format is invalid."""
|
||||
|
||||
def __init__(self, gtin: str, message: str = "Invalid GTIN format"):
|
||||
super().__init__(
|
||||
message=f"{message}: {gtin}",
|
||||
field="gtin",
|
||||
details={"gtin": gtin},
|
||||
)
|
||||
self.error_code = "INVALID_GTIN"
|
||||
|
||||
|
||||
class ProductCSVImportException(BusinessLogicException):
|
||||
"""Raised when product CSV import fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Product CSV import failed",
|
||||
row_number: Optional[int] = None,
|
||||
errors: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
details = {}
|
||||
if row_number:
|
||||
details["row_number"] = row_number
|
||||
if errors:
|
||||
details["errors"] = errors
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="PRODUCT_CSV_IMPORT_FAILED",
|
||||
details=details,
|
||||
)
|
||||
157
app/exceptions/shop.py
Normal file
157
app/exceptions/shop.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# app/exceptions/shop.py
|
||||
"""
|
||||
Shop management specific exceptions.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import (
|
||||
ResourceNotFoundException,
|
||||
ConflictException,
|
||||
ValidationException,
|
||||
AuthorizationException,
|
||||
BusinessLogicException
|
||||
)
|
||||
|
||||
|
||||
class ShopNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a shop is not found."""
|
||||
|
||||
def __init__(self, shop_identifier: str, identifier_type: str = "code"):
|
||||
if identifier_type.lower() == "id":
|
||||
message = f"Shop with ID '{shop_identifier}' not found"
|
||||
else:
|
||||
message = f"Shop with code '{shop_identifier}' not found"
|
||||
|
||||
super().__init__(
|
||||
resource_type="Shop",
|
||||
identifier=shop_identifier,
|
||||
message=message,
|
||||
error_code="SHOP_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class ShopAlreadyExistsException(ConflictException):
|
||||
"""Raised when trying to create a shop that already exists."""
|
||||
|
||||
def __init__(self, shop_code: str):
|
||||
super().__init__(
|
||||
message=f"Shop with code '{shop_code}' already exists",
|
||||
error_code="SHOP_ALREADY_EXISTS",
|
||||
details={"shop_code": shop_code},
|
||||
)
|
||||
|
||||
|
||||
class ShopNotActiveException(BusinessLogicException):
|
||||
"""Raised when trying to perform operations on inactive shop."""
|
||||
|
||||
def __init__(self, shop_code: str):
|
||||
super().__init__(
|
||||
message=f"Shop '{shop_code}' is not active",
|
||||
error_code="SHOP_NOT_ACTIVE",
|
||||
details={"shop_code": shop_code},
|
||||
)
|
||||
|
||||
|
||||
class ShopNotVerifiedException(BusinessLogicException):
|
||||
"""Raised when trying to perform operations requiring verified shop."""
|
||||
|
||||
def __init__(self, shop_code: str):
|
||||
super().__init__(
|
||||
message=f"Shop '{shop_code}' is not verified",
|
||||
error_code="SHOP_NOT_VERIFIED",
|
||||
details={"shop_code": shop_code},
|
||||
)
|
||||
|
||||
|
||||
class UnauthorizedShopAccessException(AuthorizationException):
|
||||
"""Raised when user tries to access shop they don't own."""
|
||||
|
||||
def __init__(self, shop_code: str, user_id: Optional[int] = None):
|
||||
details = {"shop_code": shop_code}
|
||||
if user_id:
|
||||
details["user_id"] = user_id
|
||||
|
||||
super().__init__(
|
||||
message=f"Unauthorized access to shop '{shop_code}'",
|
||||
error_code="UNAUTHORIZED_SHOP_ACCESS",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class InvalidShopDataException(ValidationException):
|
||||
"""Raised when shop data is invalid."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Invalid shop data",
|
||||
field: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "INVALID_SHOP_DATA"
|
||||
|
||||
|
||||
class ShopProductAlreadyExistsException(ConflictException):
|
||||
"""Raised when trying to add a product that already exists in shop."""
|
||||
|
||||
def __init__(self, shop_code: str, product_id: str):
|
||||
super().__init__(
|
||||
message=f"Product '{product_id}' already exists in shop '{shop_code}'",
|
||||
error_code="SHOP_PRODUCT_ALREADY_EXISTS",
|
||||
details={
|
||||
"shop_code": shop_code,
|
||||
"product_id": product_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ShopProductNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a shop product relationship is not found."""
|
||||
|
||||
def __init__(self, shop_code: str, product_id: str):
|
||||
super().__init__(
|
||||
resource_type="ShopProduct",
|
||||
identifier=f"{shop_code}/{product_id}",
|
||||
message=f"Product '{product_id}' not found in shop '{shop_code}'",
|
||||
error_code="SHOP_PRODUCT_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class MaxShopsReachedException(BusinessLogicException):
|
||||
"""Raised when user tries to create more shops than allowed."""
|
||||
|
||||
def __init__(self, max_shops: int, user_id: Optional[int] = None):
|
||||
details = {"max_shops": max_shops}
|
||||
if user_id:
|
||||
details["user_id"] = user_id
|
||||
|
||||
super().__init__(
|
||||
message=f"Maximum number of shops reached ({max_shops})",
|
||||
error_code="MAX_SHOPS_REACHED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
class ShopValidationException(ValidationException):
|
||||
"""Raised when shop validation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Shop validation failed",
|
||||
field: Optional[str] = None,
|
||||
validation_errors: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
details = {}
|
||||
if validation_errors:
|
||||
details["validation_errors"] = validation_errors
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "SHOP_VALIDATION_FAILED"
|
||||
132
app/exceptions/stock.py
Normal file
132
app/exceptions/stock.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# app/exceptions/stock.py
|
||||
"""
|
||||
Stock management specific exceptions.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ResourceNotFoundException, ValidationException, BusinessLogicException
|
||||
|
||||
|
||||
class StockNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when stock record is not found."""
|
||||
|
||||
def __init__(self, identifier: str, identifier_type: str = "ID"):
|
||||
if identifier_type.lower() == "gtin":
|
||||
message = f"No stock found for GTIN '{identifier}'"
|
||||
else:
|
||||
message = f"Stock record with {identifier_type} '{identifier}' not found"
|
||||
|
||||
super().__init__(
|
||||
resource_type="Stock",
|
||||
identifier=identifier,
|
||||
message=message,
|
||||
error_code="STOCK_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class InsufficientStockException(BusinessLogicException):
|
||||
"""Raised when trying to remove more stock than available."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gtin: str,
|
||||
location: str,
|
||||
requested: int,
|
||||
available: int,
|
||||
):
|
||||
message = f"Insufficient stock for GTIN '{gtin}' at '{location}'. Requested: {requested}, Available: {available}"
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="INSUFFICIENT_STOCK",
|
||||
details={
|
||||
"gtin": gtin,
|
||||
"location": location,
|
||||
"requested_quantity": requested,
|
||||
"available_quantity": available,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvalidStockOperationException(ValidationException):
|
||||
"""Raised when stock operation is invalid."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
operation: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not details:
|
||||
details = {}
|
||||
|
||||
if operation:
|
||||
details["operation"] = operation
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "INVALID_STOCK_OPERATION"
|
||||
|
||||
|
||||
class StockValidationException(ValidationException):
|
||||
"""Raised when stock data validation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Stock validation failed",
|
||||
field: Optional[str] = None,
|
||||
validation_errors: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
details = {}
|
||||
if validation_errors:
|
||||
details["validation_errors"] = validation_errors
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
field=field,
|
||||
details=details,
|
||||
)
|
||||
self.error_code = "STOCK_VALIDATION_FAILED"
|
||||
|
||||
|
||||
class NegativeStockException(BusinessLogicException):
|
||||
"""Raised when stock quantity would become negative."""
|
||||
|
||||
def __init__(self, gtin: str, location: str, resulting_quantity: int):
|
||||
message = f"Stock operation would result in negative quantity ({resulting_quantity}) for GTIN '{gtin}' at '{location}'"
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="NEGATIVE_STOCK_NOT_ALLOWED",
|
||||
details={
|
||||
"gtin": gtin,
|
||||
"location": location,
|
||||
"resulting_quantity": resulting_quantity,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class InvalidQuantityException(ValidationException):
|
||||
"""Raised when quantity value is invalid."""
|
||||
|
||||
def __init__(self, quantity: Any, message: str = "Invalid quantity"):
|
||||
super().__init__(
|
||||
message=f"{message}: {quantity}",
|
||||
field="quantity",
|
||||
details={"quantity": quantity},
|
||||
)
|
||||
self.error_code = "INVALID_QUANTITY"
|
||||
|
||||
|
||||
class LocationNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when stock location is not found."""
|
||||
|
||||
def __init__(self, location: str):
|
||||
super().__init__(
|
||||
resource_type="Location",
|
||||
identifier=location,
|
||||
message=f"Stock location '{location}' not found",
|
||||
error_code="LOCATION_NOT_FOUND",
|
||||
)
|
||||
Reference in New Issue
Block a user