Source code for exlab_wizard.api.errors

"""Error envelope helpers + FastAPI exception handlers. Backend Spec §4.6.3.

Every error response across the API uses the §4.6.3 JSON shape::

    {
      "error": {
        "code": "validation_failed",
        "message": "Operator field cannot be empty.",
        "field": "operator",
        "details": { "min_length": 1 },
        "trace_id": "abc123def456"
      }
    }

Required: ``code`` (stable string identifier; this is what client code
branches on), ``message`` (human-readable). Optional: ``field``
(field-level validation errors), ``details`` (free-form structured
detail), ``trace_id`` (echoed from the request's ``X-Trace-Id`` header
if present, else server-generated; correlates with the central app log).

The full ``code`` enum table is in §4.6.3; :data:`ERROR_CODES` mirrors
it as a closed string set so adding a new code requires updating both
the spec section and this module in the same change.
"""

from __future__ import annotations

import secrets
from collections.abc import Mapping
from typing import Any

from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError as PydanticValidationError

from exlab_wizard.errors import (
    ConfigError,
    KeyringUnavailableError,
    SchemaMajorMismatchError,
    SetupIncompleteError,
    TemplateCoreFieldRedeclaredError,
    TemplateLoadError,
)
from exlab_wizard.errors import (
    ValidationError as ExLabValidationError,
)
from exlab_wizard.logging import get_logger

__all__ = [
    "ERROR_CODES",
    "EW_TRACE_ID_HEADER",
    "build_error_envelope",
    "error_response",
    "extract_or_create_trace_id",
    "register_exception_handlers",
]

_log = get_logger(__name__)

# HTTP header that carries the per-request trace id. Mirrors §4.6.3's
# "echoed back from the request's X-Trace-Id header if present" clause.
EW_TRACE_ID_HEADER = "X-Trace-Id"

# Closed string set of error codes per the §4.6.3 table. Each value
# matches a row in the spec's "code | HTTP status | Emitted by" table.
ERROR_CODES: frozenset[str] = frozenset(
    {
        "setup_incomplete",
        "shutting_down",
        "validation_failed",
        "plugin_variable_validation_failed",
        "template_load_error",
        "template_core_field_redeclared",
        "lims_unreachable",
        "keyring_unavailable",
        "session_not_found",
        "session_already_completed",
        "nas_sync_failed",
        "schema_major_mismatch",
        "equipment_id_invalid",
        "field_too_long",
        "disk_space_insufficient",
        "plugin_host_unavailable",
        "orchestrator_disabled",
        "staging_not_sync_verified",
        "internal_error",
    }
)


[docs] def extract_or_create_trace_id(request: Request | None) -> str: """Return the request's ``X-Trace-Id`` header, else a fresh hex id. Server-generated ids use 12 hex characters of cryptographic randomness (``secrets.token_hex(6)``); plenty of bits for log correlation in a single-user desktop app. """ if request is not None: header = request.headers.get(EW_TRACE_ID_HEADER) if header: return header return secrets.token_hex(6)
[docs] def build_error_envelope( *, code: str, message: str, field: str | None = None, details: Mapping[str, Any] | None = None, trace_id: str | None = None, ) -> dict[str, Any]: """Build the §4.6.3 envelope dict. ``code`` is validated against :data:`ERROR_CODES`; an unknown code is replaced with ``"internal_error"`` and logged at WARN so the client always gets a known discriminator. """ if code not in ERROR_CODES: _log.warning("unknown error code %r; substituting 'internal_error'", code) code = "internal_error" error: dict[str, Any] = { "code": code, "message": message, } if field is not None: error["field"] = field if details is not None: error["details"] = dict(details) if trace_id is not None: error["trace_id"] = trace_id return {"error": error}
[docs] def error_response( *, request: Request | None, code: str, message: str, status_code: int, field: str | None = None, details: Mapping[str, Any] | None = None, extra: Mapping[str, Any] | None = None, ) -> JSONResponse: """Build a FastAPI JSONResponse carrying the §4.6.3 envelope. ``extra`` is merged into the ``error`` block alongside the four standard fields. The setup-incomplete handler uses this to attach ``state`` and ``missing`` per §4.9.2. """ trace_id = extract_or_create_trace_id(request) envelope = build_error_envelope( code=code, message=message, field=field, details=details, trace_id=trace_id, ) if extra: envelope["error"].update(extra) headers = {EW_TRACE_ID_HEADER: trace_id} return JSONResponse(envelope, status_code=status_code, headers=headers)
# --------------------------------------------------------------------------- # Exception -> envelope translators # --------------------------------------------------------------------------- def _exlab_validation_handler(request: Request, exc: ExLabValidationError) -> JSONResponse: """Translate :class:`exlab_wizard.errors.ValidationError` to envelope. The controller raises ValidationError with a structured envelope as the first arg (see ``CreationController._format_error``). When the envelope is present we use its fields directly; otherwise we fall back to the exception's string form. """ payload: dict[str, Any] = {} if exc.args and isinstance(exc.args[0], dict): payload = dict(exc.args[0]) code = payload.get("code", "validation_failed") return error_response( request=request, code=str(code), message=str(payload.get("message", str(exc))), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, field=payload.get("field"), details=payload.get("details"), ) def _pydantic_validation_handler( request: Request, exc: PydanticValidationError | RequestValidationError ) -> JSONResponse: """Translate Pydantic validation errors to the §4.6.3 envelope. FastAPI raises :class:`fastapi.exceptions.RequestValidationError` for body / query mismatches; we translate the first error into the envelope shape and stuff every error's path into ``details.errors``. """ errors = list(exc.errors()) first = errors[0] if errors else {} field_loc = first.get("loc", ()) # Drop the "body"/"query"/etc prefix Pydantic adds; the API surface # only cares about the field name from the body itself. field_name: str | None if isinstance(field_loc, (list, tuple)) and field_loc: if field_loc[0] in ("body", "query", "path", "header"): field_loc = tuple(field_loc[1:]) field_name = ".".join(str(part) for part in field_loc) if field_loc else None else: field_name = None return error_response( request=request, code="validation_failed", message=str(first.get("msg", "request validation failed")), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, field=field_name, details={"errors": [_pydantic_error_to_dict(e) for e in errors]}, ) def _pydantic_error_to_dict(error: dict[str, Any]) -> dict[str, Any]: """Strip Pydantic error dicts of unhashable / large fields.""" return { "loc": list(error.get("loc", ())), "msg": error.get("msg"), "type": error.get("type"), } def _config_error_handler(request: Request, exc: ConfigError) -> JSONResponse: return error_response( request=request, code="validation_failed", message=str(exc), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, ) def _keyring_unavailable_handler(request: Request, exc: KeyringUnavailableError) -> JSONResponse: return error_response( request=request, code="keyring_unavailable", message=str(exc) or "the OS keyring backend is unavailable", status_code=status.HTTP_503_SERVICE_UNAVAILABLE, ) def _schema_major_mismatch_handler(request: Request, exc: SchemaMajorMismatchError) -> JSONResponse: return error_response( request=request, code="schema_major_mismatch", message=str(exc), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, details={"expected_major": exc.expected_major, "found": exc.found}, ) def _setup_incomplete_handler(request: Request, exc: SetupIncompleteError) -> JSONResponse: """Translate :class:`SetupIncompleteError` to the §4.9.2 envelope. The controller raises this when a creation flow is invoked while ``state`` is any ``INCOMPLETE_*`` other than the soft block. The envelope adds ``state`` and ``missing`` as extra fields per the spec. """ extra: dict[str, Any] = {} if exc.args and isinstance(exc.args[0], dict): payload = exc.args[0] if "state" in payload: extra["state"] = payload["state"] if "missing" in payload: extra["missing"] = list(payload["missing"]) return error_response( request=request, code="setup_incomplete", message=str(exc) or "setup is incomplete", status_code=status.HTTP_503_SERVICE_UNAVAILABLE, extra=extra, ) def _template_load_error_handler(request: Request, exc: TemplateLoadError) -> JSONResponse: code = ( "template_core_field_redeclared" if isinstance(exc, TemplateCoreFieldRedeclaredError) else "template_load_error" ) return error_response( request=request, code=code, message=str(exc), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, ) def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: """Translate an :class:`HTTPException` into the §4.6.3 envelope. Routers raise ``HTTPException(status_code=..., detail=<dict>)`` with a ``code``-bearing dict. We hoist those fields onto the envelope so every error response is shape-identical. """ detail = exc.detail if isinstance(detail, dict) and "code" in detail: code = str(detail.get("code", "internal_error")) message = str(detail.get("message", str(exc.detail))) field = detail.get("field") details = detail.get("details") extra = { k: v for k, v in detail.items() if k not in {"code", "message", "field", "details"} } return error_response( request=request, code=code, message=message, status_code=exc.status_code, field=field, details=details, extra=extra or None, ) return error_response( request=request, code=_status_to_code(exc.status_code), message=str(detail) if detail is not None else "", status_code=exc.status_code, ) def _status_to_code(status_code: int) -> str: """Best-effort default code for a bare ``HTTPException``.""" if status_code == 404: return "session_not_found" if status_code == 409: return "session_already_completed" if status_code == 503: return "setup_incomplete" if status_code == 422: return "validation_failed" return "internal_error" def _generic_handler(request: Request, exc: Exception) -> JSONResponse: """Catch-all 500 handler. Backend Spec §4.6.3 last paragraph. The exception's message is stripped from the envelope to avoid leaking internals; a server-side log entry retains the full traceback. ``trace_id`` is generated and returned to the client so support can correlate the report with the log line. """ trace_id = extract_or_create_trace_id(request) _log.exception("internal_error trace_id=%s", trace_id) return error_response( request=request, code="internal_error", message="internal server error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, details={"trace_id": trace_id}, )
[docs] def register_exception_handlers(app: FastAPI) -> None: """Attach the §4.6.3 envelope handlers to a FastAPI app. Registered in priority order (FastAPI dispatch is most-specific first by ``isinstance`` tree; the order below is exhaustive enough that the order at registration is mostly cosmetic). Each handler is typed against its concrete exception subclass for readability, but Starlette's ``add_exception_handler`` is typed to accept callables over the base ``Exception`` -- the dispatcher only invokes a handler when the instance matches its registered type, so the per-handler signatures are sound at runtime. We funnel the registrations through :func:`_register` so the necessary cast lives in one place. """ _register(app, SetupIncompleteError, _setup_incomplete_handler) _register(app, KeyringUnavailableError, _keyring_unavailable_handler) _register(app, SchemaMajorMismatchError, _schema_major_mismatch_handler) _register(app, TemplateLoadError, _template_load_error_handler) _register(app, ConfigError, _config_error_handler) _register(app, ExLabValidationError, _exlab_validation_handler) _register(app, RequestValidationError, _pydantic_validation_handler) _register(app, PydanticValidationError, _pydantic_validation_handler) _register(app, HTTPException, _http_exception_handler) _register(app, Exception, _generic_handler)
def _register( app: FastAPI, exc_type: type[Exception], handler: Any, ) -> None: """Wrap ``app.add_exception_handler`` so the handler is accepted as ``Any``. Starlette's signature wants ``Callable[[Request, Exception], ...]``; our handlers are typed against narrower exception subclasses for clarity. The dispatcher only invokes a handler when the runtime instance matches ``exc_type``, so the wider type at registration is sound. ``handler`` is typed as ``Any`` to avoid a per-call cast. """ app.add_exception_handler(exc_type, handler)