Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart with Orion/orion/ORION across 184 files. This includes database identifiers, email addresses, domain references, R2 bucket names, DNS prefixes, encryption salt, Celery app name, config defaults, Docker configs, CI configs, documentation, seed data, and templates. Renames homepage-wizamart.html template to homepage-orion.html. Fixes duplicate file_pattern key in api.yaml architecture rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
481 lines
17 KiB
Python
481 lines
17 KiB
Python
# app/exceptions/handler.py
|
|
"""
|
|
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
|
|
- Context-aware HTML error pages
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import HTTPException, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
|
|
from app.modules.enums import FrontendType
|
|
from middleware.frontend_type import get_frontend_type
|
|
|
|
from .base import OrionException
|
|
from .error_renderer import ErrorPageRenderer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def setup_exception_handlers(app):
|
|
"""Setup exception handlers for the FastAPI app."""
|
|
|
|
@app.exception_handler(OrionException)
|
|
async def custom_exception_handler(request: Request, exc: OrionException):
|
|
"""Handle custom exceptions with context-aware rendering."""
|
|
|
|
# Special handling for auth errors on HTML page requests (redirect to login)
|
|
# This includes both:
|
|
# - 401 errors: Not authenticated (expired/invalid token)
|
|
# - 403 errors with specific auth codes: Authenticated but wrong context
|
|
# (e.g., store token on admin page, role mismatch)
|
|
# These codes indicate the user should re-authenticate with correct credentials
|
|
auth_redirect_error_codes = {
|
|
# Auth-level errors
|
|
"ADMIN_REQUIRED",
|
|
"INSUFFICIENT_PERMISSIONS",
|
|
"USER_NOT_ACTIVE",
|
|
# Store-level auth errors
|
|
"STORE_ACCESS_DENIED",
|
|
"UNAUTHORIZED_STORE_ACCESS",
|
|
"STORE_OWNER_ONLY",
|
|
"INSUFFICIENT_STORE_PERMISSIONS",
|
|
# Customer-level auth errors
|
|
"CUSTOMER_NOT_AUTHORIZED",
|
|
}
|
|
|
|
should_redirect = (
|
|
_is_html_page_request(request)
|
|
and (
|
|
exc.status_code == 401
|
|
or (exc.status_code == 403 and exc.error_code in auth_redirect_error_codes)
|
|
)
|
|
)
|
|
|
|
if should_redirect:
|
|
logger.info(
|
|
f"Auth error on HTML page request - redirecting to login: {request.url.path}",
|
|
extra={
|
|
"path": request.url.path,
|
|
"status_code": exc.status_code,
|
|
"error_code": exc.error_code,
|
|
"accept": request.headers.get("accept", ""),
|
|
"method": request.method,
|
|
},
|
|
)
|
|
|
|
# Redirect to appropriate login page based on context
|
|
return _redirect_to_login(request)
|
|
|
|
# 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,
|
|
"exception_type": type(exc).__name__,
|
|
},
|
|
)
|
|
|
|
# Check if this is an API request
|
|
if _is_api_request(request):
|
|
return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
|
|
|
|
# Check if this is an HTML page request
|
|
if _is_html_page_request(request):
|
|
return ErrorPageRenderer.render_error_page(
|
|
request=request,
|
|
status_code=exc.status_code,
|
|
error_code=exc.error_code,
|
|
message=exc.message,
|
|
details=exc.details,
|
|
show_debug=True, # Will be filtered by ErrorPageRenderer based on user role
|
|
)
|
|
|
|
# Default to JSON for unknown request types
|
|
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,
|
|
"exception_type": "HTTPException",
|
|
},
|
|
)
|
|
|
|
# Check if this is an API request
|
|
if _is_api_request(request):
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={
|
|
"error_code": f"HTTP_{exc.status_code}",
|
|
"message": exc.detail,
|
|
"status_code": exc.status_code,
|
|
},
|
|
)
|
|
|
|
# Check if this is an HTML page request
|
|
if _is_html_page_request(request):
|
|
return ErrorPageRenderer.render_error_page(
|
|
request=request,
|
|
status_code=exc.status_code,
|
|
error_code=f"HTTP_{exc.status_code}",
|
|
message=exc.detail,
|
|
details={},
|
|
show_debug=True,
|
|
)
|
|
|
|
# Default to JSON
|
|
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: Request, exc: RequestValidationError
|
|
):
|
|
"""Handle Pydantic validation errors with consistent format."""
|
|
|
|
# Sanitize errors to remove sensitive data from logs
|
|
sanitized_errors = []
|
|
for error in exc.errors():
|
|
sanitized_error = error.copy()
|
|
# Remove 'input' field which may contain passwords
|
|
if "input" in sanitized_error:
|
|
sanitized_error["input"] = "<redacted>"
|
|
sanitized_errors.append(sanitized_error)
|
|
|
|
logger.error(
|
|
f"Validation error in {request.method} {request.url}: {len(sanitized_errors)} validation error(s)",
|
|
extra={
|
|
"validation_errors": sanitized_errors,
|
|
"url": str(request.url),
|
|
"method": request.method,
|
|
"exception_type": "RequestValidationError",
|
|
},
|
|
)
|
|
|
|
# Clean up validation errors to ensure JSON serializability
|
|
clean_errors = []
|
|
for error in exc.errors():
|
|
clean_error = {}
|
|
for key, value in error.items():
|
|
if key == "input" and isinstance(value, bytes):
|
|
# Convert bytes to string representation for JSON serialization
|
|
clean_error[key] = f"<bytes: {len(value)} bytes>"
|
|
elif key == "ctx" and isinstance(value, dict):
|
|
# Handle the 'ctx' field that contains ValueError objects
|
|
clean_ctx = {}
|
|
for ctx_key, ctx_value in value.items():
|
|
if isinstance(ctx_value, Exception):
|
|
clean_ctx[ctx_key] = str(
|
|
ctx_value
|
|
) # Convert exception to string
|
|
else:
|
|
clean_ctx[ctx_key] = ctx_value
|
|
clean_error[key] = clean_ctx
|
|
elif isinstance(value, bytes):
|
|
# Handle any other bytes objects
|
|
clean_error[key] = f"<bytes: {len(value)} bytes>"
|
|
else:
|
|
clean_error[key] = value
|
|
clean_errors.append(clean_error)
|
|
|
|
# Check if this is an API request
|
|
if _is_api_request(request):
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={
|
|
"error_code": "VALIDATION_ERROR",
|
|
"message": "Request validation failed",
|
|
"status_code": 422,
|
|
"details": {"validation_errors": clean_errors},
|
|
},
|
|
)
|
|
|
|
# Check if this is an HTML page request
|
|
if _is_html_page_request(request):
|
|
return ErrorPageRenderer.render_error_page(
|
|
request=request,
|
|
status_code=422,
|
|
error_code="VALIDATION_ERROR",
|
|
message="Request validation failed",
|
|
details={"validation_errors": clean_errors},
|
|
show_debug=True,
|
|
)
|
|
|
|
# Default to JSON
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={
|
|
"error_code": "VALIDATION_ERROR",
|
|
"message": "Request validation failed",
|
|
"status_code": 422,
|
|
"details": {"validation_errors": clean_errors},
|
|
},
|
|
)
|
|
|
|
@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__,
|
|
},
|
|
)
|
|
|
|
# Check if this is an API request
|
|
if _is_api_request(request):
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error_code": "INTERNAL_SERVER_ERROR",
|
|
"message": "Internal server error",
|
|
"status_code": 500,
|
|
},
|
|
)
|
|
|
|
# Check if this is an HTML page request
|
|
if _is_html_page_request(request):
|
|
return ErrorPageRenderer.render_error_page(
|
|
request=request,
|
|
status_code=500,
|
|
error_code="INTERNAL_SERVER_ERROR",
|
|
message="Internal server error",
|
|
details={},
|
|
show_debug=True,
|
|
)
|
|
|
|
# Default to JSON
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error_code": "INTERNAL_SERVER_ERROR",
|
|
"message": "Internal server error",
|
|
"status_code": 500,
|
|
},
|
|
)
|
|
|
|
@app.exception_handler(404)
|
|
async def not_found_handler(request: Request, exc):
|
|
"""Handle all 404 errors with consistent format."""
|
|
logger.warning(f"404 Not Found: {request.method} {request.url}")
|
|
|
|
# Check if this is an API request
|
|
if _is_api_request(request):
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"error_code": "ENDPOINT_NOT_FOUND",
|
|
"message": f"Endpoint not found: {request.url.path}",
|
|
"status_code": 404,
|
|
"details": {"path": request.url.path, "method": request.method},
|
|
},
|
|
)
|
|
|
|
# Check if this is an HTML page request
|
|
if _is_html_page_request(request):
|
|
return ErrorPageRenderer.render_error_page(
|
|
request=request,
|
|
status_code=404,
|
|
error_code="ENDPOINT_NOT_FOUND",
|
|
message="The page you're looking for doesn't exist or has been moved.",
|
|
details={"path": request.url.path},
|
|
show_debug=True,
|
|
)
|
|
|
|
# Default to JSON
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"error_code": "ENDPOINT_NOT_FOUND",
|
|
"message": f"Endpoint not found: {request.url.path}",
|
|
"status_code": 404,
|
|
"details": {"path": request.url.path, "method": request.method},
|
|
},
|
|
)
|
|
|
|
|
|
def _is_api_request(request: Request) -> bool:
|
|
"""
|
|
Check if the request is for an API endpoint.
|
|
|
|
API requests ALWAYS return JSON.
|
|
"""
|
|
return request.url.path.startswith("/api/")
|
|
|
|
|
|
def _is_html_page_request(request: Request) -> bool:
|
|
"""
|
|
Check if the request is for an HTML page (not an API endpoint).
|
|
|
|
More precise detection:
|
|
- Must NOT have /api/ in path
|
|
- Must be GET request
|
|
- Must explicitly accept text/html
|
|
- Must not already be on login page
|
|
"""
|
|
logger.debug(
|
|
f"Checking if HTML page request: {request.url.path}",
|
|
extra={
|
|
"path": request.url.path,
|
|
"method": request.method,
|
|
"accept": request.headers.get("accept", ""),
|
|
},
|
|
)
|
|
|
|
# Don't redirect API calls
|
|
if _is_api_request(request):
|
|
logger.debug("Not HTML page: API endpoint")
|
|
return False
|
|
|
|
# Don't redirect if already on login page
|
|
if request.url.path.endswith("/login"):
|
|
logger.debug("Not HTML page: Already on login page")
|
|
return False
|
|
|
|
# Only redirect GET requests (page loads)
|
|
if request.method != "GET":
|
|
logger.debug(f"Not HTML page: Method is {request.method}, not GET")
|
|
return False
|
|
|
|
# MUST explicitly accept HTML (strict check)
|
|
accept_header = request.headers.get("accept", "")
|
|
if "text/html" not in accept_header:
|
|
logger.debug(
|
|
f"Not HTML page: Accept header doesn't include text/html: {accept_header}"
|
|
)
|
|
return False
|
|
|
|
logger.debug("IS HTML page request")
|
|
return True
|
|
|
|
|
|
def _redirect_to_login(request: Request) -> RedirectResponse:
|
|
"""
|
|
Redirect to appropriate login page based on request frontend type.
|
|
|
|
Uses FrontendType detection to determine admin vs store vs storefront login.
|
|
Properly handles multi-access routing (domain, subdomain, path-based).
|
|
"""
|
|
frontend_type = get_frontend_type(request)
|
|
|
|
if frontend_type == FrontendType.ADMIN:
|
|
logger.debug("Redirecting to /admin/login")
|
|
return RedirectResponse(url="/admin/login", status_code=302)
|
|
if frontend_type == FrontendType.MERCHANT:
|
|
logger.debug("Redirecting to /merchants/login")
|
|
return RedirectResponse(url="/merchants/login", status_code=302)
|
|
if frontend_type == FrontendType.STORE:
|
|
# Extract store code from the request path
|
|
# Path format: /store/{store_code}/...
|
|
path_parts = request.url.path.split("/")
|
|
store_code = None
|
|
|
|
# Find store code in path
|
|
if len(path_parts) >= 3 and path_parts[1] == "store":
|
|
store_code = path_parts[2]
|
|
|
|
# Fallback: try to get from request state
|
|
if not store_code:
|
|
store = getattr(request.state, "store", None)
|
|
if store:
|
|
store_code = store.subdomain
|
|
|
|
# Construct proper login URL with store code
|
|
if store_code:
|
|
login_url = f"/store/{store_code}/login"
|
|
else:
|
|
# Fallback if we can't determine store code
|
|
login_url = "/store/login"
|
|
|
|
logger.debug(f"Redirecting to {login_url}")
|
|
return RedirectResponse(url=login_url, status_code=302)
|
|
if frontend_type == FrontendType.STOREFRONT:
|
|
# For storefront context, redirect to storefront login (customer login)
|
|
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
|
|
store = getattr(request.state, "store", None)
|
|
store_context = getattr(request.state, "store_context", None)
|
|
access_method = (
|
|
store_context.get("detection_method", "unknown")
|
|
if store_context
|
|
else "unknown"
|
|
)
|
|
|
|
base_url = "/"
|
|
if access_method == "path" and store:
|
|
full_prefix = (
|
|
store_context.get("full_prefix", "/store/")
|
|
if store_context
|
|
else "/store/"
|
|
)
|
|
base_url = f"{full_prefix}{store.subdomain}/"
|
|
|
|
login_url = f"{base_url}storefront/account/login"
|
|
logger.debug(f"Redirecting to {login_url}")
|
|
return RedirectResponse(url=login_url, status_code=302)
|
|
# Fallback to root for unknown contexts (PLATFORM)
|
|
logger.debug("Platform context, redirecting to /")
|
|
return RedirectResponse(url="/", status_code=302)
|
|
|
|
|
|
# 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)
|