Source code for pydotorg.core.exceptions

"""Site-wide exception handlers with HTMX-aware flash message support."""

from __future__ import annotations

import json
import logging
from typing import TYPE_CHECKING
from urllib.parse import quote

from litestar.exceptions import (
    HTTPException,
    InternalServerException,
    NotFoundException,
    PermissionDeniedException,
)
from litestar.response import Redirect, Response, Template
from litestar.status_codes import (
    HTTP_401_UNAUTHORIZED,
    HTTP_403_FORBIDDEN,
    HTTP_404_NOT_FOUND,
    HTTP_500_INTERNAL_SERVER_ERROR,
)

if TYPE_CHECKING:
    from litestar import Request

logger = logging.getLogger(__name__)


def _is_htmx_request(request: Request) -> bool:
    """Check if the request is an HTMX request."""
    return request.headers.get("HX-Request") == "true"


def _is_api_request(request: Request) -> bool:
    """Check if the request is an API request.

    Returns True if:
    - Path starts with /api/
    - Accept header includes application/json
    """
    path = request.url.path
    accept = request.headers.get("Accept", "")
    return path.startswith("/api/") or "application/json" in accept


def _create_json_error_response(
    detail: str,
    status_code: int,
    extra: dict | None = None,
) -> Response:
    """Create a JSON error response for API requests.

    Args:
        detail: Error message
        status_code: HTTP status code
        extra: Additional error details

    Returns:
        JSON response with error information
    """
    content = {"detail": detail, "status_code": status_code}
    if extra:
        content["extra"] = extra
    return Response(
        content=content,
        status_code=status_code,
        media_type="application/json",
    )


def _create_toast_response(
    message: str,
    toast_type: str = "error",
    status_code: int = HTTP_404_NOT_FOUND,
) -> Response:
    """Create an HTMX-compatible response that triggers a toast notification.

    Args:
        message: The message to display in the toast
        toast_type: Type of toast (error, warning, info, success)
        status_code: HTTP status code for the response

    Returns:
        Response with HX-Trigger header for toast display
    """
    toast_event = json.dumps(
        {
            "showToast": {
                "message": message,
                "type": toast_type,
            }
        }
    )
    return Response(
        content=None,
        status_code=status_code,
        headers={"HX-Trigger": toast_event, "HX-Reswap": "none"},
    )


CACHE_404_MAX_AGE = 300


[docs] def not_found_exception_handler(request: Request, exc: NotFoundException) -> Response | Template: """Handle 404 Not Found exceptions site-wide. For API requests: Returns JSON error response. For HTMX requests: Returns empty response with HX-Trigger for toast notification. For regular requests: Renders the 404 error template with 5-minute cache. Args: request: The incoming request exc: The NotFoundException that was raised Returns: JSON response, HTMX toast response, or rendered error template """ path = request.url.path detail = exc.detail if exc.detail else "Resource not found" logger.info("404 Not Found: %s", path) if _is_api_request(request): return _create_json_error_response(detail=detail, status_code=HTTP_404_NOT_FOUND) friendly_name = path.strip("/").replace("/", " → ").replace("-", " ").title() or "Home" friendly_detail = f"'{friendly_name}' is not available yet. This feature is coming soon." if _is_htmx_request(request): return _create_toast_response( message=friendly_detail, toast_type="info", status_code=HTTP_404_NOT_FOUND, ) return Template( template_name="errors/404.html.jinja2", context={ "title": "Page Not Found", "message": friendly_detail, "path": path, }, status_code=HTTP_404_NOT_FOUND, headers={"Cache-Control": f"max-age={CACHE_404_MAX_AGE}"}, )
[docs] def http_exception_handler(request: Request, exc: HTTPException) -> Response | Template | Redirect: """Handle generic HTTP exceptions site-wide. For API requests: Returns JSON error response. For HTMX requests: Returns toast notification. For browser requests: Returns HTML template or redirect. Args: request: The incoming request exc: The HTTPException that was raised Returns: Appropriate response based on request type and exception """ status_code = exc.status_code detail = exc.detail if exc.detail else f"An error occurred (HTTP {status_code})" extra = getattr(exc, "extra", None) logger.warning("HTTP %d on %s: %s", status_code, request.url.path, detail) if _is_api_request(request): return _create_json_error_response(detail=detail, status_code=status_code, extra=extra) if _is_htmx_request(request): toast_type = "warning" if status_code < HTTP_500_INTERNAL_SERVER_ERROR else "error" return _create_toast_response( message=detail, toast_type=toast_type, status_code=status_code, ) if status_code == HTTP_401_UNAUTHORIZED: next_url = quote(str(request.url), safe="") return Redirect(f"/auth/login?next={next_url}") if status_code == HTTP_403_FORBIDDEN: return Template( template_name="errors/403.html.jinja2", context={ "title": "Access Denied", "message": detail, }, status_code=status_code, ) return Template( template_name="errors/generic.html.jinja2", context={ "title": f"Error {status_code}", "message": detail, "status_code": status_code, }, status_code=status_code, )
[docs] def permission_denied_handler(request: Request, exc: PermissionDeniedException) -> Response | Template: """Handle permission denied exceptions. For API requests: Returns JSON error response. For HTMX requests: Returns toast notification. For browser requests: Returns 403 template. Args: request: The incoming request exc: The PermissionDeniedException that was raised Returns: JSON response, HTMX toast response, or rendered 403 template """ detail = exc.detail if exc.detail else "You do not have permission to access this resource." logger.warning("403 Forbidden on %s", request.url.path) if _is_api_request(request): return _create_json_error_response(detail=detail, status_code=HTTP_403_FORBIDDEN) if _is_htmx_request(request): return _create_toast_response( message=detail, toast_type="error", status_code=HTTP_403_FORBIDDEN, ) return Template( template_name="errors/403.html.jinja2", context={ "title": "Access Denied", "message": detail, }, status_code=HTTP_403_FORBIDDEN, )
[docs] def internal_server_error_handler(request: Request, exc: InternalServerException) -> Response | Template: """Handle internal server errors. For API requests: Returns JSON error response. For HTMX requests: Returns toast notification. For browser requests: Returns 500 template. Args: request: The incoming request exc: The InternalServerException that was raised Returns: JSON response, HTMX toast response, or rendered 500 template """ logger.exception("500 Internal Server Error on %s", request.url.path) message = "An unexpected error occurred. Please try again later." if _is_api_request(request): return _create_json_error_response(detail=message, status_code=HTTP_500_INTERNAL_SERVER_ERROR) if _is_htmx_request(request): return _create_toast_response( message=message, toast_type="error", status_code=HTTP_500_INTERNAL_SERVER_ERROR, ) return Template( template_name="errors/500.html.jinja2", context={ "title": "Server Error", "message": message, }, status_code=HTTP_500_INTERNAL_SERVER_ERROR, )
[docs] def get_exception_handlers() -> dict: """Get all site-wide exception handlers. Returns: Dictionary mapping exception types to their handlers """ return { NotFoundException: not_found_exception_handler, PermissionDeniedException: permission_denied_handler, InternalServerException: internal_server_error_handler, HTTPException: http_exception_handler, }