refactor: error handling integration and testing standards

This commit is contained in:
matt 2026-03-17 17:29:14 -07:00
parent f784741416
commit f23c0dbf2c
10 changed files with 1038 additions and 8 deletions

View file

@ -22,6 +22,8 @@ from .services.combo_utils import detect_all as _detect_all
from .services.theme_catalog_loader import prewarm_common_filters, load_index
from .services.commander_catalog_loader import load_commander_catalog
from .services.tasks import get_session, new_sid, set_session_value
from code.exceptions import DeckBuilderError
from .utils.responses import deck_builder_error_response
# Logger for app-level logging
logger = logging.getLogger(__name__)
@ -2403,6 +2405,13 @@ async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPE
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
# Handle DeckBuilderError subclasses with structured responses before falling to 500
if isinstance(exc, DeckBuilderError):
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
logging.getLogger("web").warning(
f"DeckBuilderError [rid={rid}] {type(exc).__name__} {request.method} {request.url.path}: {exc}"
)
return deck_builder_error_response(request, exc)
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
logging.getLogger("web").error(
f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Iterable, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from deck_builder.combined_commander import PartnerMode
@ -10,6 +10,7 @@ from deck_builder.combined_commander import PartnerMode
from ..app import ENABLE_PARTNER_MECHANICS
from ..services.partner_suggestions import get_partner_suggestions
from ..services.telemetry import log_partner_suggestions_generated
from code.exceptions import CommanderValidationError, FeatureDisabledError
router = APIRouter(prefix="/api/partner", tags=["partner suggestions"])
@ -65,11 +66,11 @@ async def partner_suggestions_api(
refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"),
):
if not ENABLE_PARTNER_MECHANICS:
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
raise FeatureDisabledError("partner_suggestions")
commander_name = (commander or "").strip()
if not commander_name:
raise HTTPException(status_code=400, detail="Commander name is required")
raise CommanderValidationError("Commander name is required")
include_modes = _parse_modes(mode)
result = get_partner_suggestions(
@ -79,7 +80,7 @@ async def partner_suggestions_api(
refresh_dataset=refresh,
)
if result is None:
raise HTTPException(status_code=503, detail="Partner suggestion dataset is unavailable")
raise FeatureDisabledError("partner_suggestion_dataset")
partner_names = _coerce_name_list(partner)
background_names = _coerce_name_list(background)

View file

@ -156,3 +156,97 @@ def merge_hx_trigger(response: HTMLResponse, events: Dict[str, Any]) -> None:
response.headers["HX-Trigger"] = json.dumps(events)
else:
response.headers["HX-Trigger"] = json.dumps(events)
# --- DeckBuilderError integration ---
def is_htmx_request(request: Request) -> bool:
"""Return True if the request was made by HTMX."""
try:
return request.headers.get("HX-Request") == "true"
except Exception:
return False
# Map DeckBuilderError subclass names to HTTP status codes.
# More specific subclasses should appear before their parents.
_EXCEPTION_STATUS_MAP: list[tuple[str, int]] = [
# Web-specific
("SessionExpiredError", 401),
("BuildNotFoundError", 404),
("FeatureDisabledError", 404),
# Commander
("CommanderValidationError", 400),
("CommanderTypeError", 400),
("CommanderColorError", 400),
("CommanderTagError", 400),
("CommanderPartnerError", 400),
("CommanderSelectionError", 400),
("CommanderLoadError", 503),
# Theme
("ThemeSelectionError", 400),
("ThemeWeightError", 400),
("ThemeError", 400),
# Price
("PriceLimitError", 400),
("PriceValidationError", 400),
("PriceAPIError", 503),
("PriceError", 400),
# CSV / setup data unavailable
("CSVFileNotFoundError", 503),
("MTGJSONDownloadError", 503),
("EmptyDataFrameError", 503),
("CSVError", 503),
("MTGSetupError", 503),
]
def deck_error_to_status(exc: Exception) -> int:
"""Return the appropriate HTTP status code for a DeckBuilderError."""
exc_type = type(exc).__name__
for name, status in _EXCEPTION_STATUS_MAP:
if exc_type == name:
return status
# Walk MRO for inexact matches (subclasses not listed above)
for cls in type(exc).__mro__:
for name, status in _EXCEPTION_STATUS_MAP:
if cls.__name__ == name:
return status
return 500
def deck_builder_error_response(request: Request, exc: Exception) -> JSONResponse | HTMLResponse:
"""Convert a DeckBuilderError to an appropriate HTTP response.
Returns an HTML error fragment for HTMX requests, JSON otherwise.
Includes request_id and standardized structure.
"""
import time
status = deck_error_to_status(exc)
request_id = getattr(getattr(request, "state", None), "request_id", None) or "unknown"
# User-safe message: use .message attribute if present, else str()
message = getattr(exc, "message", None) or str(exc)
error_type = type(exc).__name__
code = getattr(exc, "code", error_type)
if is_htmx_request(request):
html = (
f'<div class="error-banner" role="alert">'
f'<strong>{status}</strong> {message}'
f'</div>'
)
return HTMLResponse(content=html, status_code=status, headers={"X-Request-ID": request_id})
payload: Dict[str, Any] = {
"error": True,
"status": status,
"error_type": error_type,
"code": code,
"message": message,
"path": str(request.url.path),
"request_id": request_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
return JSONResponse(content=payload, status_code=status, headers={"X-Request-ID": request_id})