diff --git a/src/aare/common/exception_handler.py b/src/aare/common/exception_handler.py index 72e4bce3..909a0a7f 100644 --- a/src/aare/common/exception_handler.py +++ b/src/aare/common/exception_handler.py @@ -1,15 +1,81 @@ from __future__ import annotations import time +from typing import ClassVar from aare.common.logger_config import setup_logger from aare.common.error_codes import AuthErrorCode, DAQErrorCode logger = setup_logger("aareDAQ") -class TransformationInvalidException(Exception): - def __init__(self, message: str = "Transformation is not implemented"): - super().__init__(message) + +class AareException(Exception): + """Root of the aare exception hierarchy. + + Class-level ``critical`` is the default; pass ``critical=...`` to the + constructor of an "optionally critical" subclass to override on a single + raise site. + """ + + critical: ClassVar[bool] = False + + def __init__(self, *args, critical: bool | None = None, **kwargs): + super().__init__(*args, **kwargs) + if critical is not None: + self.critical = critical + + +class AutomationError(AareException): + """Errors that happen during a DAQ operation; route through automation flows.""" + + +class AareUserError(AareException): + """Bad input / mode misuse; routes through the input-correction flow.""" + + +class AareAuthError(AareException): + """Authentication / authorization failures; routes through re-auth flow.""" + + +# --------------------------------------------------------------------------- +# Device-family base classes +# +# These exist purely so watchers can match a whole device family via +# ``isinstance(e, TellException)`` etc. Per the plan they are intentionally +# empty -- do not add behavior. +# --------------------------------------------------------------------------- + +class TellException(AutomationError): + pass + + +class SmargonException(AutomationError): + pass + + +class AerotechException(AutomationError): + pass + + +class JFJochException(AutomationError): + pass + + +class BECException(AutomationError): + pass + + +class BeamlineStateException(AutomationError): + pass + + +# --------------------------------------------------------------------------- +# Concrete automation exceptions +# --------------------------------------------------------------------------- + +class TransformationInvalidException(AutomationError): + def __init__(self, message: str = "Transformation is not implemented", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -17,9 +83,9 @@ class TransformationInvalidException(Exception): return self.message -class StateTransitionFailed(Exception): - def __init__(self, message: str = "Beamline state transition failed"): - super().__init__(message) +class StateTransitionFailed(BeamlineStateException): + def __init__(self, message: str = "Beamline state transition failed", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -27,9 +93,9 @@ class StateTransitionFailed(Exception): return self.message -class MaintenanceStateException(Exception): - def __init__(self, message: str = "Beamline is in Maintenance state"): - super().__init__(message) +class MaintenanceStateException(BeamlineStateException): + def __init__(self, message: str = "Beamline is in Maintenance state", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -37,31 +103,33 @@ class MaintenanceStateException(Exception): return self.message -class DataCollectionException(Exception): - def __init__(self, message: str = "Data collection failed"): - super().__init__(message) +class DataCollectionException(AutomationError): + """Group parent for data-collection-time exceptions; also raisable directly.""" + + def __init__(self, message: str = "Data collection failed", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message def __str__(self) -> str: return self.message -class RasterScanException(Exception): - def __init__(self, message: str = "Data collection failed"): - super().__init__(message) +class RasterScanException(DataCollectionException): + def __init__(self, message: str = "Data collection failed", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message def __str__(self) -> str: return self.message -class AutoRasterSampleSkipped(Exception): +class AutoRasterSampleSkipped(AutomationError): """Raised when automation should skip the current sample because auto-raster is too large.""" -class LoopCenteringFailed(Exception): - def __init__(self, message: str = "Loop Centering did not detect a sample"): - super().__init__(message) +class LoopCenteringFailed(AutomationError): + def __init__(self, message: str = "Loop Centering did not detect a sample", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -69,9 +137,11 @@ class LoopCenteringFailed(Exception): return self.message -class UnmountingFailed(Exception): - def __init__(self, message: str = "A sample was not unmounted"): - super().__init__(message) +class UnmountingFailed(TellException): + """Sample failed to unmount. Optionally critical via instance flag.""" + + def __init__(self, message: str = "A sample was not unmounted", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -79,9 +149,14 @@ class UnmountingFailed(Exception): return self.message -class MountingFailed(Exception): - def __init__(self, message: str = "A sample was not mounted"): - super().__init__(message) +class MountingFailed(TellException): + """Sample failed to mount. Optionally critical via instance flag. + + Absorbs ``TellMountFailedException`` (still present for back-compat during + migration; will be removed in Phase 3).""" + + def __init__(self, message: str = "A sample was not mounted", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -89,10 +164,10 @@ class MountingFailed(Exception): return self.message -class ManualMountException(Exception): +class ManualMountException(AareUserError): """Custom exception for manual mounting""" - def __init__(self, message: str = "Manual mounting failed"): - super().__init__(message) + def __init__(self, message: str = "Manual mounting failed", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -100,10 +175,10 @@ class ManualMountException(Exception): return self.message -class SmartMagnetFaultException(Exception): +class SmartMagnetFaultException(AutomationError): """Custom exception for smart magnet fault""" - def __init__(self, message: str = "Smart magnet fault"): - super().__init__(message) + def __init__(self, message: str = "Smart magnet fault", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -111,11 +186,13 @@ class SmartMagnetFaultException(Exception): return self.message -class TellMountFailedException(Exception): - """Custom exception for mount failure""" +class TellMountFailedException(TellException): + """Deprecated -- will be absorbed by MountingFailed in Phase 3. - def __init__(self, message: str = "Tell mount failed"): - super().__init__(message) + Currently kept as a TellException family member for back-compat.""" + + def __init__(self, message: str = "Tell mount failed", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -123,11 +200,11 @@ class TellMountFailedException(Exception): return self.message -class TellCommandWhileBusyException(Exception): +class TellCommandWhileBusyException(TellException): """Custom exception for trying to move Tell when it is busy""" - def __init__(self, message: str = "Tell is busy"): - super().__init__(message) + def __init__(self, message: str = "Tell is busy", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -135,11 +212,11 @@ class TellCommandWhileBusyException(Exception): return self.message -class TellConnectionException(Exception): +class TellConnectionException(TellException): """Custom exception for connection problems""" - def __init__(self, message: str = "Lost connection to Tell"): - super().__init__(message) + def __init__(self, message: str = "Lost connection to Tell", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -147,9 +224,9 @@ class TellConnectionException(Exception): return self.message -class WarningTellException(Exception): - def __init__(self, message: str = "Warning error in TELL"): - super().__init__(message) +class WarningTellException(TellException): + def __init__(self, message: str = "Warning error in TELL", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -157,9 +234,9 @@ class WarningTellException(Exception): return self.message -class CriticalTellException(Exception): - def __init__(self, message: str = "Critical error in TELL"): - super().__init__(message) +class CriticalTellException(TellException): + def __init__(self, message: str = "Critical error in TELL", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -167,9 +244,9 @@ class CriticalTellException(Exception): return self.message -class AXCFailed(Exception): - def __init__(self, message: str = "Auto X-ray centering failed"): - super().__init__(message) +class AXCFailed(AutomationError): + def __init__(self, message: str = "Auto X-ray centering failed", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -177,9 +254,9 @@ class AXCFailed(Exception): return self.message -class BeamlineBusyException(Exception): - def __init__(self, message: str = "Beamline is in busy state"): - super().__init__(message) +class BeamlineBusyException(BeamlineStateException): + def __init__(self, message: str = "Beamline is in busy state", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -187,9 +264,9 @@ class BeamlineBusyException(Exception): return self.message -class BeamlineBusyTimeoutException(Exception): - def __init__(self, message: str = "Beamline busy state expired during operation"): - super().__init__(message) +class BeamlineBusyTimeoutException(BeamlineStateException): + def __init__(self, message: str = "Beamline busy state expired during operation", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -197,9 +274,15 @@ class BeamlineBusyTimeoutException(Exception): return self.message -class SampleException(Exception): - def __init__(self, message: str = "Sample not found"): - super().__init__(message) +class SampleException(AareUserError): + """Requested sample could not be located. + + NOTE: not listed in the redesign hierarchy (ยง1.2); parented under + AareUserError pending confirmation -- "sample not found" reads as bad + input rather than a runtime failure.""" + + def __init__(self, message: str = "Sample not found", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error(message, extra={"exception:": Exception}) @@ -207,14 +290,19 @@ class SampleException(Exception): return self.message -class AuthenticationException(Exception): +# --------------------------------------------------------------------------- +# Auth exceptions +# --------------------------------------------------------------------------- + +class AuthenticationException(AareAuthError): def __init__(self, message: str = "Authentication failed.", *, status_code: int = 401, headers: dict[str, str] | None = None, - code: AuthErrorCode = AuthErrorCode.AUTHENTICATION_FAILED): - super().__init__(message) + code: AuthErrorCode = AuthErrorCode.AUTHENTICATION_FAILED, + critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message self.status_code = status_code self.headers = headers @@ -225,16 +313,18 @@ class AuthenticationException(Exception): return self.message -class UserRightsException(Exception): +class UserRightsException(AareAuthError): _last_log_ts_by_message: dict[str, float] = {} _throttle_window_s = 30.0 + def __init__(self, message: str = "User does not have rights to perform this action.", *, status_code: int = 403, headers: dict[str, str] | None = None, - code: AuthErrorCode = AuthErrorCode.FORBIDDEN): - super().__init__(message) + code: AuthErrorCode = AuthErrorCode.FORBIDDEN, + critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message self.status_code = status_code self.headers = headers @@ -250,7 +340,11 @@ class UserRightsException(Exception): return self.message -class SmargonCommunicationError(Exception): +# --------------------------------------------------------------------------- +# Device communication exceptions +# --------------------------------------------------------------------------- + +class SmargonCommunicationError(SmargonException): """ Raised when Smargon HTTP communication fails (connection refused, timeout, bad HTTP status, etc). Keep the original exception in `__cause__` by using `raise ... from e`. @@ -262,10 +356,11 @@ class SmargonCommunicationError(Exception): *, endpoint: str | None = None, base_url: str | None = None, - operation: str | None = None, # e.g. "GET" / "PUT" + operation: str | None = None, status_code: int | None = None, + critical: bool | None = None, ): - super().__init__(message) + super().__init__(message, critical=critical) self.message = message self.endpoint = endpoint self.base_url = base_url @@ -286,7 +381,7 @@ class SmargonCommunicationError(Exception): return self.message -class TellCommunicationError(Exception): +class TellCommunicationError(TellException): """ Raised when TELL HTTP/PShell communication fails (timeouts, connection refused, etc). Intended to be caught centrally by FastAPI exception handlers. @@ -298,9 +393,10 @@ class TellCommunicationError(Exception): *, endpoint: str | None = None, base_url: str | None = None, - operation: str | None = None, # e.g. "GET" + operation: str | None = None, + critical: bool | None = None, ): - super().__init__(message) + super().__init__(message, critical=critical) self.message = message self.endpoint = endpoint self.base_url = base_url @@ -318,7 +414,8 @@ class TellCommunicationError(Exception): def __str__(self) -> str: return self.message -class JFJochCommunicationError(Exception): + +class JFJochCommunicationError(JFJochException): """ Raised when JFJoch HTTP/API communication fails. Intended for scan-time fallbacks and GUI-visible alerts. @@ -332,8 +429,9 @@ class JFJochCommunicationError(Exception): endpoint: str | None = None, base_url: str | None = None, status_code: int | None = None, + critical: bool | None = None, ): - super().__init__(message) + super().__init__(message, critical=critical) self.message = message self.operation = operation self.endpoint = endpoint @@ -353,8 +451,13 @@ class JFJochCommunicationError(Exception): def __str__(self) -> str: return self.message -class AareDBCommunicationError(Exception): - """Raised when AareDB HTTPS communication fails.""" + +class AareDBCommunicationError(AutomationError): + """Raised when AareDB HTTPS communication fails. + + Default not critical -- DB hiccups don't always halt automation. Raise with + ``critical=True`` at call sites where a DB read failure should escalate.""" + def __init__( self, message: str = "AareDB communication error", @@ -363,8 +466,9 @@ class AareDBCommunicationError(Exception): endpoint: str | None = None, base_url: str | None = None, status_code: int | None = None, + critical: bool | None = None, ): - super().__init__(message) + super().__init__(message, critical=critical) self.message = message self.operation = operation self.endpoint = endpoint @@ -384,7 +488,8 @@ class AareDBCommunicationError(Exception): def __str__(self) -> str: return self.message -class AerotechCommunicationError(Exception): + +class AerotechCommunicationError(AerotechException): """ Raised when Aerotech HTTP/API communication fails (connection refused, timeout, bad HTTP status, etc). Keep the original exception in `__cause__` by using `raise ... from e`. @@ -398,8 +503,9 @@ class AerotechCommunicationError(Exception): base_url: str | None = None, operation: str | None = None, status_code: int | None = None, + critical: bool | None = None, ): - super().__init__(message) + super().__init__(message, critical=critical) self.message = message self.endpoint = endpoint self.base_url = base_url @@ -419,9 +525,10 @@ class AerotechCommunicationError(Exception): def __str__(self) -> str: return self.message -class MagnetPositionSensorErorr(Exception): - def __init__(self, message: str = "Magnet position sensor error"): - super().__init__(message) + +class MagnetPositionSensorErorr(AutomationError): + def __init__(self, message: str = "Magnet position sensor error", *, critical: bool | None = None): + super().__init__(message, critical=critical) self.message = message logger.error( message, @@ -431,7 +538,8 @@ class MagnetPositionSensorErorr(Exception): def __str__(self) -> str: return self.message -class BECCommunicationError(Exception): + +class BECCommunicationError(BECException): def __init__( self, message: str = "BEC communication error", @@ -440,7 +548,9 @@ class BECCommunicationError(Exception): operation: str | None = None, endpoint: str | None = None, base_url: str | None = None, + critical: bool | None = None, ): + super().__init__(message, critical=critical) self.message = message self.operation = operation self.exception = exception