exception hadnling updates

This commit is contained in:
2026-05-22 18:06:01 +02:00
parent 3654e126e6
commit 3801c11e8d
3 changed files with 510 additions and 349 deletions
+162 -279
View File
@@ -1,319 +1,202 @@
# aare/common/server_error_handler.py
"""Server-side exception handlers for the DAQ FastAPI app.
Per the exception-handling redesign (EXCEPTION_REDESIGN_PLAN.md §2), error
handling is collapsed into a small number of handlers that route on the new
``AareException`` hierarchy:
- ``AutomationError``: 503 if ``critical``, else 422
- ``AareUserError``: 400
- ``AareAuthError``: 401 (auth) or 403 (rights)
- ``HTTPException``: pass-through status, with the new body shape
- ``Exception``: 500, marked critical (fail-safe for unknown errors)
All handlers produce the unified response body defined in §3:
.. code-block:: json
{
"critical": true,
"code": "TELL_COMMUNICATION_ERROR",
"exception_class": "TellCommunicationError",
"message": "Tell did not respond within 5s",
"context": {"endpoint": "/state", "operation": "GET"}
}
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import HTTPException
from fastapi import status as api_status
from starlette.requests import Request
from starlette.responses import JSONResponse
from aare.common.logger_config import setup_logger
from aare.common.error_codes import AareErrorCode, code_for_exception_class
from aare.common.exception_handler import (
MountingFailed,
WarningTellException,
CriticalTellException,
LoopCenteringFailed,
TransformationInvalidException,
StateTransitionFailed,
MaintenanceStateException,
BeamlineBusyException,
AareException,
AutomationError,
AareUserError,
AareAuthError,
AuthenticationException,
SampleException,
UserRightsException,
SmargonCommunicationError,
TellCommunicationError,
BECCommunicationError,
JFJochCommunicationError,
AerotechCommunicationError,
DataCollectionException,
RasterScanException,
UnmountingFailed,
AXCFailed,
AareDBCommunicationError,
MagnetPositionSensorErorr,
ManualMountException,
SmartMagnetFaultException,
TellMountFailedException,
TellCommandWhileBusyException,
TellConnectionException,
)
logger = setup_logger("aareDAQ")
def _error_payload(*, code: str, message: str, extra: dict | None = None) -> dict:
payload = {"code": code, "message": message}
if extra:
payload["extra"] = extra
return payload
# Attributes that live on exceptions for routing/transport, NOT for the
# context payload.
_NON_CONTEXT_ATTRS = frozenset({
"args",
"message",
"critical",
"code",
"headers",
"status_code",
"exception", # BECCommunicationError stores a Python exception here -- not JSON-safe
})
def _extract_context(exc: Exception) -> dict[str, Any]:
"""Pull JSON-serializable structured fields off an exception for the
response ``context`` field.
Allow-list approach via ``_NON_CONTEXT_ATTRS`` exclusions: walks
``vars(exc)`` and includes anything that's not private, not in the
excluded set, and JSON-serializable. Per plan §3 the schema is
per-exception; clients tolerate unknown keys."""
ctx: dict[str, Any] = {}
for k, v in vars(exc).items():
if k.startswith("_"):
continue
if k in _NON_CONTEXT_ATTRS:
continue
if v is None:
continue
try:
json.dumps(v)
except (TypeError, ValueError):
continue
ctx[k] = v
return ctx
def _error_body(exc: Exception, *, code_override: str | None = None,
critical_override: bool | None = None) -> dict[str, Any]:
"""Build the unified error response body for any exception.
``code_override`` is used by the auth handler to surface the finer-grained
``AuthErrorCode`` carried on the instance, instead of the class-derived
code. ``critical_override`` is used by the bare-Exception fallback to
force critical=True regardless of the exception's own flag."""
if code_override is not None:
code = code_override
else:
code = code_for_exception_class(type(exc).__name__)
if critical_override is not None:
critical = critical_override
else:
# Default for non-AareException is critical=True (fail-safe).
critical = bool(getattr(exc, "critical", True))
return {
"critical": critical,
"code": code,
"exception_class": type(exc).__name__,
"message": str(exc) or type(exc).__name__,
"context": _extract_context(exc),
}
def register_exception_handlers(app) -> None:
"""
Register server-wide exception handlers on the given FastAPI app.
Call once right after `app = FastAPI()`.
Register the four routing-root handlers (plus HTTPException + Exception
fallback) on the given FastAPI app. Call once right after
``app = FastAPI()``.
"""
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
# Keep explicit HTTP errors, but normalize response shape
detail = exc.detail
if isinstance(detail, dict) and "code" in detail and "message" in detail:
body = detail
else:
body = _error_payload(code="HTTP_ERROR", message=str(detail))
return JSONResponse(status_code=exc.status_code, content=body, headers=exc.headers)
@app.exception_handler(MountingFailed)
async def mounting_failed_handler(request: Request, exc: MountingFailed) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_409_CONFLICT,
content=_error_payload(code="MOUNTING_FAILED", message=str(exc) or "Failed to mount sample"),
@app.exception_handler(AutomationError)
async def _handle_automation_error(request: Request, exc: AutomationError) -> JSONResponse:
status = (
api_status.HTTP_503_SERVICE_UNAVAILABLE
if exc.critical
else api_status.HTTP_422_UNPROCESSABLE_ENTITY
)
return JSONResponse(status_code=status, content=_error_body(exc))
@app.exception_handler(UnmountingFailed)
async def unmounting_failed_handler(request: Request, exc: UnmountingFailed) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(code="UNMOUNTING_FAILED", message=str(exc) or "Failed to unmount sample"),
)
@app.exception_handler(WarningTellException)
async def warning_tell_handler(request: Request, exc: WarningTellException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_410_GONE,
content=_error_payload(code="TELL_WARNING", message=str(exc)),
)
@app.exception_handler(CriticalTellException)
async def critical_tell_handler(request: Request, exc: CriticalTellException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_417_EXPECTATION_FAILED,
content=_error_payload(code="TELL_CRITICAL", message=str(exc)),
)
@app.exception_handler(LoopCenteringFailed)
async def loop_centering_failed_handler(request: Request, exc: LoopCenteringFailed) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_404_NOT_FOUND,
content=_error_payload(code="LOOP_CENTERING_FAILED", message=str(exc)),
)
@app.exception_handler(AXCFailed)
async def axc_failed_handler(request: Request, exc: AXCFailed) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(code="AXC_FAILED", message=str(exc) or "Auto X-ray centering failed"),
)
@app.exception_handler(TransformationInvalidException)
async def transformation_invalid_handler(request: Request, exc: TransformationInvalidException) -> JSONResponse:
@app.exception_handler(AareUserError)
async def _handle_user_error(request: Request, exc: AareUserError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_400_BAD_REQUEST,
content=_error_payload(code="TRANSFORMATION_INVALID", message=str(exc)),
content=_error_body(exc),
)
@app.exception_handler(StateTransitionFailed)
async def state_transition_failed_handler(request: Request, exc: StateTransitionFailed) -> JSONResponse:
@app.exception_handler(AareAuthError)
async def _handle_auth_error(request: Request, exc: AareAuthError) -> JSONResponse:
# Auth flow needs the finer-grained AuthErrorCode (e.g. INVALID_TOKEN
# vs SESSION_ALREADY_ACTIVE) so the GUI can route between re-auth /
# claim-session / login dialogs. Fall back to the class-derived code
# when the instance carries no `code` attribute.
instance_code = getattr(exc, "code", None)
code_override = str(instance_code) if instance_code is not None else None
status = getattr(exc, "status_code", None)
if status is None:
status = (
api_status.HTTP_401_UNAUTHORIZED
if isinstance(exc, AuthenticationException)
else api_status.HTTP_403_FORBIDDEN
)
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(code="STATE_TRANSITION_FAILED", message=str(exc)),
)
@app.exception_handler(MaintenanceStateException)
async def maintenance_state_handler(request: Request, exc: MaintenanceStateException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_409_CONFLICT,
content=_error_payload(code="MAINTENANCE_STATE", message=str(exc)),
)
@app.exception_handler(BeamlineBusyException)
async def beamline_busy_handler(request: Request, exc: BeamlineBusyException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_423_LOCKED,
content=_error_payload(code="BEAMLINE_BUSY", message=str(exc) or "Beamline is busy"),
)
@app.exception_handler(DataCollectionException)
async def data_collection_failed_handler(request: Request, exc: DataCollectionException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(code="DATA_COLLECTION_FAILED", message=str(exc) or "Data collection failed"),
)
@app.exception_handler(RasterScanException)
async def raster_scan_failed_handler(request: Request, exc: RasterScanException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(code="RASTER_SCAN_FAILED", message=str(exc) or "Raster scan failed"),
)
@app.exception_handler(AuthenticationException)
async def authentication_exception_handler(request: Request, exc: AuthenticationException) -> JSONResponse:
return JSONResponse(
status_code=getattr(exc, "status_code", api_status.HTTP_401_UNAUTHORIZED),
content=_error_payload(
code=str(getattr(exc, "code", "AUTHENTICATION_ERROR")),
message=str(exc) or "Invalid authentication",
),
status_code=status,
content=_error_body(exc, code_override=code_override),
headers=getattr(exc, "headers", None),
)
@app.exception_handler(UserRightsException)
async def user_rights_exception_handler(request: Request, exc: UserRightsException) -> JSONResponse:
return JSONResponse(
status_code=getattr(exc, "status_code", api_status.HTTP_403_FORBIDDEN),
content=_error_payload(
code=str(getattr(exc, "code", "FORBIDDEN")),
message=str(exc) or "Forbidden",
),
headers=getattr(exc, "headers", None),
)
@app.exception_handler(SampleException)
async def sample_exception_handler(request: Request, exc: SampleException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_404_NOT_FOUND,
content=_error_payload(code="SAMPLE_NOT_FOUND", message=str(exc) or "Sample not found"),
)
@app.exception_handler(ManualMountException)
async def manual_mount_handler(request: Request, exc: ManualMountException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_409_CONFLICT,
content=_error_payload(code="MANUAL_MOUNT_REQUIRED", message=str(exc) or "Manual mount intervention required"),
)
@app.exception_handler(SmartMagnetFaultException)
async def smart_magnet_fault_handler(request: Request, exc: SmartMagnetFaultException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(code="SMART_MAGNET_FAULT", message=str(exc) or "Smart magnet fault"),
)
@app.exception_handler(TellMountFailedException)
async def tell_mount_failed_handler(request: Request, exc: TellMountFailedException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_409_CONFLICT,
content=_error_payload(code="TELL_MOUNT_FAILED", message=str(exc) or "TELL mount failed"),
)
@app.exception_handler(TellCommandWhileBusyException)
async def tell_busy_handler(request: Request, exc: TellCommandWhileBusyException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_409_CONFLICT,
content=_error_payload(code="TELL_BUSY", message=str(exc) or "TELL is busy"),
)
@app.exception_handler(TellConnectionException)
async def tell_connection_error_handler(request: Request, exc: TellConnectionException) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(code="TELL_CONNECTION_ERROR", message=str(exc) or "TELL connection error"),
)
@app.exception_handler(SmargonCommunicationError)
async def smargon_comm_handler(request: Request, exc: SmargonCommunicationError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="SMARGON_UNAVAILABLE",
message=str(exc) or "Smargon is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
@app.exception_handler(HTTPException)
async def _handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
"""HTTPException isn't in the AareException hierarchy but FastAPI raises
it internally (e.g. validation, route-not-found) and routes raise it
manually. Adapt to the new body shape so clients see one schema."""
detail = exc.detail
# Preserve legacy callers that passed a {code, message} dict
if isinstance(detail, dict) and "code" in detail and "message" in detail:
legacy_code = str(detail.get("code"))
legacy_message = str(detail.get("message"))
return JSONResponse(
status_code=exc.status_code,
content={
"critical": exc.status_code >= 500,
"code": legacy_code,
"exception_class": "HTTPException",
"message": legacy_message,
"context": {k: v for k, v in detail.items() if k not in ("code", "message")},
},
),
)
@app.exception_handler(AareDBCommunicationError)
async def aaredb_comm_handler(request: Request, exc: AareDBCommunicationError) -> JSONResponse:
headers=exc.headers,
)
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="AAREDB_UNAVAILABLE",
message=str(exc) or "AareDB is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
"base_url": getattr(exc, "base_url", None),
},
),
)
@app.exception_handler(TellCommunicationError)
async def tell_comm_handler(request: Request, exc: TellCommunicationError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="TELL_UNAVAILABLE",
message=str(exc) or "TELL is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
},
),
)
@app.exception_handler(BECCommunicationError)
async def bec_comm_handler(request: Request, exc: BECCommunicationError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="BEC_UNAVAILABLE",
message=str(exc) or "BEC is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
"base_url": getattr(exc, "base_url", None),
},
),
)
@app.exception_handler(JFJochCommunicationError)
async def jfjoch_comm_handler(request: Request, exc: JFJochCommunicationError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="JFJOCH_UNAVAILABLE",
message=str(exc) or "JFJoch detector is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
"base_url": getattr(exc, "base_url", None),
},
),
)
@app.exception_handler(AerotechCommunicationError)
async def aerotech_comm_handler(request: Request, exc: AerotechCommunicationError) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
content=_error_payload(
code="AEROTECH_UNAVAILABLE",
message=str(exc) or "Aerotech is unavailable",
extra={
"operation": getattr(exc, "operation", None),
"endpoint": getattr(exc, "endpoint", None),
"base_url": getattr(exc, "base_url", None),
},
),
)
@app.exception_handler(MagnetPositionSensorErorr)
async def magnet_position_sensor_handler(request: Request, exc: MagnetPositionSensorErorr) -> JSONResponse:
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(
code="MAGNET_POSITION_SENSOR_ERROR",
message=str(exc) or "Magnet position sensor error",
),
status_code=exc.status_code,
content={
"critical": exc.status_code >= 500,
"code": "HTTP_ERROR",
"exception_class": "HTTPException",
"message": str(detail) or "HTTP error",
"context": {},
},
headers=exc.headers,
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception(f"Unhandled server exception: {exc}")
async def _handle_unknown(request: Request, exc: Exception) -> JSONResponse:
"""Fail-safe: any unrecognized exception is treated as critical."""
logger.exception("Unhandled exception in DAQ", exc_info=exc)
return JSONResponse(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_payload(code="INTERNAL_SERVER_ERROR", message=str(exc) or "Internal server error"),
content=_error_body(
exc,
code_override=str(AareErrorCode.INTERNAL_ERROR),
critical_override=True,
),
)