mtg_python_deckbuilder/docs/web_backend/error_handling.md
mwisnowski ac6c9f4daa
Some checks failed
CI / build (push) Has been cancelled
docs: documentation overhaul - archive, user guides, env parity (#62)
* docs: archive CLI runner scripts and Windows Docker guide, update web runner scripts

* docs: overhaul README and DOCKER.md, add 10 user guides

- README: Budget Mode section, corrected Further Reading links, theme badge descriptions, diagnostics expansion
- DOCKER.md: Windows path note, Budget Mode + Include/Exclude sections, env table additions
- docs/user_guides/: 10 new feature guides covering budget mode, include/exclude, locks/replace/permalinks, batch build, theme browser, random build, owned cards, partner mechanics, bracket compliance, quick build & skip controls

* fix: map PriceTimeoutError→503, add budget exceptions to status map; update error_handling.md

* docs: env var parity — add missing vars to .env.example and README table
2026-03-23 22:00:50 -07:00

3.9 KiB

Error Handling Guide

Overview

The web layer uses a layered error handling strategy:

  1. Typed domain exceptions (code/exceptions.py) — raised by routes and services to express semantic failures
  2. Exception handlers (code/web/app.py) — convert exceptions to appropriate HTTP responses
  3. Response utilities (code/web/utils/responses.py) — build consistent JSON or HTML fragment responses

HTMX requests get an HTML error fragment; regular API requests get JSON.


Exception Hierarchy

All custom exceptions inherit from DeckBuilderError (base) in code/exceptions.py.

Status Code Mapping

Exception HTTP Status Use When
SessionExpiredError 401 Session cookie is missing or stale
BuildNotFoundError 404 Session has no build result
FeatureDisabledError 404 Feature is off via env var
CommanderValidationError (and subclasses) 400 Invalid commander input
ThemeSelectionError 400 Invalid theme selection
ThemeError 400 General theme failure
PriceLimitError, PriceValidationError 400 Bad price constraint
PriceConfigurationError 400 Invalid budget/price configuration in determine_ideals()
BudgetHardCapExceeded 400 Deck total exceeds hard budget cap after all replacements
PriceAPIError 503 External price API down
PriceTimeoutError 503 External price API timed out
CSVFileNotFoundError 503 Card data files missing
MTGJSONDownloadError 503 Data download failure
EmptyDataFrameError 503 No card data available
DeckBuilderError (base, unrecognized) 500 Unexpected domain error

Web-Specific Exceptions

Added in M4, defined at the bottom of code/exceptions.py:

SessionExpiredError(sid="abc")        # session missing or expired
BuildNotFoundError(sid="abc")          # no build result in session
FeatureDisabledError("partner_suggestions")  # feature toggled off

Raising Exceptions in Routes

Prefer typed exceptions over HTTPException for domain failures:

# Good — semantic, gets proper status code automatically
from code.exceptions import CommanderValidationError, FeatureDisabledError

raise CommanderValidationError("Commander 'Foo' not found")
raise FeatureDisabledError("batch_build")

# Still acceptable for HTTP-level concerns (rate limits, auth)
from fastapi import HTTPException
raise HTTPException(status_code=429, detail="rate_limited")

Keep HTTPException for infrastructure concerns (rate limiting, feature flags that are pure routing decisions). Use custom exceptions for domain logic failures.


Response Shape

JSON (non-HTMX)

{
  "error": true,
  "status": 400,
  "error_type": "CommanderValidationError",
  "code": "CMD_VALID",
  "message": "Commander 'Foo' not found",
  "path": "/build/step1/confirm",
  "request_id": "a1b2c3d4",
  "timestamp": "2026-03-17T12:00:00Z"
}

HTML (HTMX requests)

<div class="error-banner" role="alert">
  <strong>400</strong> Commander 'Foo' not found
</div>

The X-Request-ID header is always set on both response types.


Adding a New Exception

  1. Add the class to code/exceptions.py inheriting from the appropriate parent
  2. Add an entry to _EXCEPTION_STATUS_MAP in code/web/utils/responses.py if the status code differs from the parent
  3. Raise it in your route or service
  4. The handler in app.py will pick it up automatically

Testing Error Handling

See code/tests/test_error_handling.py for patterns. Key fixtures:

# Minimal app with DeckBuilderError handler
app = FastAPI()

@app.exception_handler(Exception)
async def handler(request, exc):
    if isinstance(exc, DeckBuilderError):
        return deck_builder_error_response(request, exc)
    ...

client = TestClient(app, raise_server_exceptions=False)

Always pass raise_server_exceptions=False so the handler runs during tests.