Files
orion/app/exceptions/error_renderer.py

348 lines
11 KiB
Python

# app/exceptions/error_renderer.py
"""
Error Page Renderer
Renders context-aware error pages using Jinja2 templates.
Handles fallback logic and context-specific customization.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from middleware.context import RequestContext, get_request_context
logger = logging.getLogger(__name__)
class ErrorPageRenderer:
"""Renders context-aware error pages using Jinja2 templates."""
# Map status codes to friendly names
STATUS_CODE_NAMES = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
422: "Validation Error",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
}
# Map status codes to user-friendly messages
STATUS_CODE_MESSAGES = {
400: "The request could not be processed due to invalid data.",
401: "You need to be authenticated to access this resource.",
403: "You don't have permission to access this resource.",
404: "The page you're looking for doesn't exist or has been moved.",
422: "The submitted data contains validation errors.",
429: "Too many requests. Please slow down and try again later.",
500: "Something went wrong on our end. We're working to fix it.",
502: "Unable to reach the service. Please try again later.",
503: "The service is temporarily unavailable. Please try again later.",
}
@staticmethod
def get_templates_dir() -> Path:
"""Get the templates directory path."""
# Assuming templates are in app/templates/
base_dir = Path(__file__).resolve().parent.parent
return base_dir / "templates"
@staticmethod
def render_error_page(
request: Request,
status_code: int,
error_code: str,
message: str,
details: Optional[Dict[str, Any]] = None,
show_debug: bool = False,
) -> HTMLResponse:
"""
Render appropriate error page based on request context.
Template Selection Priority:
1. Context-specific error page: {context}/errors/{status_code}.html
2. Context-specific generic: {context}/errors/generic.html
3. Fallback error page: fallback/{status_code}.html
4. Fallback generic: fallback/generic.html
Args:
request: FastAPI request object
status_code: HTTP status code
error_code: Application error code
message: Error message
details: Additional error details
show_debug: Whether to show debug information
Returns:
HTMLResponse with rendered error page
"""
# Get request context
context_type = get_request_context(request)
# Prepare template data
template_data = ErrorPageRenderer._prepare_template_data(
request=request,
context_type=context_type,
status_code=status_code,
error_code=error_code,
message=message,
details=details,
show_debug=show_debug,
)
# Find and render template
templates_dir = ErrorPageRenderer.get_templates_dir()
templates = Jinja2Templates(directory=str(templates_dir))
# Try to find appropriate template
template_path = ErrorPageRenderer._find_template(
context_type=context_type,
status_code=status_code,
)
logger.info(
f"Rendering error page: {template_path} for {status_code} in {context_type.value} context",
extra={
"status_code": status_code,
"error_code": error_code,
"context": context_type.value,
"template": template_path,
}
)
try:
# Render template
return templates.TemplateResponse(
template_path,
{
"request": request,
**template_data,
},
status_code=status_code,
)
except Exception as e:
logger.error(
f"Failed to render error template {template_path}: {e}",
exc_info=True
)
# Return basic HTML as absolute fallback
return ErrorPageRenderer._render_basic_html_fallback(
status_code=status_code,
error_code=error_code,
message=message,
)
@staticmethod
def _find_template(
context_type: RequestContext,
status_code: int,
) -> str:
"""
Find appropriate error template based on context and status code.
Priority:
1. {context}/errors/{status_code}.html
2. {context}/errors/generic.html
3. fallback/{status_code}.html
4. fallback/generic.html
"""
templates_dir = ErrorPageRenderer.get_templates_dir()
# Map context type to folder name
context_folders = {
RequestContext.ADMIN: "admin",
RequestContext.VENDOR_DASHBOARD: "vendor",
RequestContext.SHOP: "shop",
RequestContext.API: "fallback", # API shouldn't get here, but just in case
RequestContext.FALLBACK: "fallback",
}
context_folder = context_folders.get(context_type, "fallback")
# Try context-specific status code template
specific_template = f"{context_folder}/errors/{status_code}.html"
if (templates_dir / specific_template).exists():
return specific_template
# Try context-specific generic template
generic_template = f"{context_folder}/errors/generic.html"
if (templates_dir / generic_template).exists():
return generic_template
# Try fallback status code template
fallback_specific = f"fallback/{status_code}.html"
if (templates_dir / fallback_specific).exists():
return fallback_specific
# Use fallback generic template (must exist)
return "fallback/generic.html"
@staticmethod
def _prepare_template_data(
request: Request,
context_type: RequestContext,
status_code: int,
error_code: str,
message: str,
details: Optional[Dict[str, Any]],
show_debug: bool,
) -> Dict[str, Any]:
"""Prepare data dictionary for error template."""
# Get friendly status name
status_name = ErrorPageRenderer.STATUS_CODE_NAMES.get(
status_code, f"Error {status_code}"
)
# Get default user-friendly message
default_message = ErrorPageRenderer.STATUS_CODE_MESSAGES.get(
status_code, "An error occurred while processing your request."
)
# Use provided message or default
user_message = message if message else default_message
# Determine if we should show debug info
# Only show to admins (we can check user role if available)
display_debug = show_debug and ErrorPageRenderer._is_admin_user(request)
# Get context-specific data
context_data = ErrorPageRenderer._get_context_data(request, context_type)
return {
"status_code": status_code,
"status_name": status_name,
"error_code": error_code,
"message": user_message,
"details": details or {},
"show_debug": display_debug,
"context_type": context_type.value,
"path": request.url.path,
**context_data,
}
@staticmethod
def _get_context_data(request: Request, context_type: RequestContext) -> Dict[str, Any]:
"""Get context-specific data for error templates."""
data = {}
# Add vendor information if available (for shop context)
if context_type == RequestContext.SHOP:
vendor = getattr(request.state, "vendor", None)
if vendor:
# Pass minimal vendor info for templates
data["vendor"] = {
"id": vendor.id,
"name": vendor.name,
"subdomain": vendor.subdomain,
"logo": getattr(vendor, "logo", None),
}
# Add theme information if available
theme = getattr(request.state, "theme", None)
if theme:
# Theme can be a dict or object, handle both
if isinstance(theme, dict):
data["theme"] = theme
else:
# If it's an object, convert to dict
data["theme"] = {
"colors": getattr(theme, "colors", {}),
"fonts": getattr(theme, "fonts", {}),
"branding": getattr(theme, "branding", {}),
"custom_css": getattr(theme, "custom_css", None),
}
return data
@staticmethod
def _is_admin_user(request: Request) -> bool:
"""
Check if current user is an admin (for debug info display).
This is a placeholder - implement based on your auth system.
"""
# TODO: Implement actual admin check based on JWT/session
# For now, check if we're in admin context
context_type = get_request_context(request)
return context_type == RequestContext.ADMIN
@staticmethod
def _render_basic_html_fallback(
status_code: int,
error_code: str,
message: str,
) -> HTMLResponse:
"""
Render a basic HTML error page as absolute fallback.
Used when template rendering fails.
"""
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{status_code} - Error</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: white;
padding: 2rem;
}}
.container {{
text-align: center;
max-width: 600px;
}}
h1 {{
font-size: 6rem;
font-weight: 700;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}}
h2 {{
font-size: 1.5rem;
font-weight: 400;
margin-bottom: 1rem;
}}
p {{
font-size: 1.1rem;
margin-bottom: 2rem;
opacity: 0.9;
}}
.error-code {{
font-size: 0.9rem;
opacity: 0.7;
font-family: 'Courier New', monospace;
}}
</style>
</head>
<body>
<div class="container">
<h1>{status_code}</h1>
<h2>Error</h2>
<p>{message}</p>
<div class="error-code">Error Code: {error_code}</div>
</div>
</body>
</html>
"""
return HTMLResponse(content=html_content, status_code=status_code)