Files
orion/app/exceptions/handler.py
Samir Boulahtit e9253fbd84 refactor: rename Wizamart to Orion across entire codebase
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>
2026-02-14 16:46:56 +01:00

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)