From 5271d4fbc473bb8f2506a90b2c929535c82892f6 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 21 Jun 2023 09:07:42 -0400 Subject: [PATCH] feat(api,shared-data): error codes in PE (#12936) On the python side of our code, we want our new enumerated exceptions to be gradually integratable, and we also want to make sure that any errors that we didn't yet get the chance to give error codes end up with error codes. To do this in a programmatic way, we can add some automated methods for wrapping python exceptions. All enumerated errors now get to wrap errors. These are optional sequences of more enumerated errors that are considered to have caused the top-level one - in most cases, this will be because the enumerated error explicitly was instantiated to wrap a python exception, but it could also be if it was raised from one. Since we only wrap other enumerated errors, we need a way to make exceptions enumerated errors. A new exception type (but not code - it's just a GeneralError) called PythonException has this capability; it lets you give it BaseExceptions in addition to other EnumeratedErrors, and it's capable of walking the python memory model internals to try and get the other exceptions in a stack of raise from ... raise from ... calls that are reasonably popular in our code. This is functionality that is promoted out of The Dunder Zone in python 3.11, so I feel pretty good using it (this is what ExceptionGroups are). So now, as in the tests, if you catch an exception and give it to a PythonException you bless it with an error code and save all the exceptions and their stack traces for later inspection. Cool! ProtocolEngine is the first place we'll go through and add places that actually use these error codes, since it's in a lovely high-leverage middle spot in our stack. That means we both get to test the upward interface of how these things will be represented in the HTTP API and how they'll be created from lower exceptions. ProtocolEngine already has its own very large and robust set of custom exceptions, which is awesome. We can make them inherit from the enumerated errors pretty easily, but unfortunately we have to add a bunch of stuff to their constructors to pass along things like the ability to wrap other exceptions and so on. Luckily that's just typing. Once we've done that, at the three points we catch all missed exceptions we have to switch over to creating the new style. ProtocolEngineErrors get passed on; uncaught legacy errors get captured as PythonExceptions; and uncaught errors in the normal core do too. Finally, we have to represent this new style of error in the ErrorOccurrence objects. This is the fun part. Previously, we'd added error codes to those objects; this was sort of a big deal because we want them to be required when you make new ErrorOccurrences and when clients look, but we don't want things to break when we deserialize old ones. We can extend that trick pretty easily to add new things. What's not quite as easy is this concept of wrapping errors. Our errors are now essentially trees, and we need tree structure here. Luckily, jsonschema and pydantic are actually pretty good at type-recursive schema and object definitions, so we can plop a list of other error occurrences in there. Now, when we catch one of these errors that's bubbled up from hardware, we give it a name and we capture its entire history in an inspectable way, and I think that's really cool. --- .../protocol_engine/clients/transports.py | 3 +- .../errors/error_occurrence.py | 44 +- .../protocol_engine/errors/exceptions.py | 497 +++++++++++++++++- .../execution/command_executor.py | 2 +- .../protocol_engine/state/commands.py | 38 +- .../protocol_runner/legacy_command_mapper.py | 19 +- .../opentrons/protocols/execution/errors.py | 20 +- .../errors/test_error_occurrence.py | 5 +- .../execution/test_command_executor.py | 2 +- .../state/test_command_store.py | 44 +- .../runs/test_json_v6_run_failure.tavern.yaml | 2 + .../runs/test_papi_v2_run_failure.tavern.yaml | 2 + .../test_pause_run_not_started.tavern.yaml | 2 +- .../tests/runs/router/test_commands_router.py | 8 +- shared-data/errors/definitions/1/errors.json | 4 + .../opentrons_shared_data/errors/__init__.py | 12 +- .../opentrons_shared_data/errors/codes.py | 1 + .../errors/exceptions.py | 237 +++++++-- .../python/tests/errors/test_exceptions.py | 74 +++ 19 files changed, 904 insertions(+), 112 deletions(-) create mode 100644 shared-data/python/tests/errors/test_exceptions.py diff --git a/api/src/opentrons/protocol_engine/clients/transports.py b/api/src/opentrons/protocol_engine/clients/transports.py index 2aa8652864d..7df76e45193 100644 --- a/api/src/opentrons/protocol_engine/clients/transports.py +++ b/api/src/opentrons/protocol_engine/clients/transports.py @@ -103,9 +103,10 @@ def execute_command(self, request: CommandCreate) -> CommandResult: loop=self._loop, ).result() + # TODO: this needs to have an actual code if command.error is not None: error = command.error - raise ProtocolEngineError(f"{error.errorType}: {error.detail}") + raise ProtocolEngineError(message=f"{error.errorType}: {error.detail}") # FIXME(mm, 2023-04-10): This assert can easily trigger from this sequence: # diff --git a/api/src/opentrons/protocol_engine/errors/error_occurrence.py b/api/src/opentrons/protocol_engine/errors/error_occurrence.py index f7928839813..d98a740fb7c 100644 --- a/api/src/opentrons/protocol_engine/errors/error_occurrence.py +++ b/api/src/opentrons/protocol_engine/errors/error_occurrence.py @@ -1,8 +1,10 @@ """Models for concrete occurrences of specific errors.""" from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, List, Type, Union from pydantic import BaseModel, Field -from .exceptions import ErrorCode +from opentrons_shared_data.errors.codes import ErrorCodes +from .exceptions import ProtocolEngineError +from opentrons_shared_data.errors.exceptions import EnumeratedError # TODO(mc, 2021-11-12): flesh this model out with structured error data @@ -10,14 +12,41 @@ class ErrorOccurrence(BaseModel): """An occurrence of a specific error during protocol execution.""" + @classmethod + def from_failed( + cls: Type["ErrorOccurrence"], + id: str, + createdAt: datetime, + error: Union[ProtocolEngineError, EnumeratedError], + ) -> "ErrorOccurrence": + """Build an ErrorOccurrence from the details available from a FailedAction or FinishAction.""" + return cls.construct( + id=id, + createdAt=createdAt, + errorType=type(error).__name__, + detail=error.message or str(error), + errorInfo=error.detail, + errorCode=error.code.value.code, + wrappedErrors=[ + cls.from_failed(id, createdAt, err) for err in error.wrapping + ], + ) + id: str = Field(..., description="Unique identifier of this error occurrence.") errorType: str = Field(..., description="Specific error type that occurred.") createdAt: datetime = Field(..., description="When the error occurred.") detail: str = Field(..., description="A human-readable message about the error.") errorCode: str = Field( - default=ErrorCode.UNKNOWN.value, + default=ErrorCodes.GENERAL_ERROR.value.code, description="An enumerated error code for the error type.", ) + errorInfo: Dict[str, str] = Field( + default={}, + description="Specific details about the error that may be useful for determining cause.", + ) + wrappedErrors: List["ErrorOccurrence"] = Field( + default=[], description="Errors that may have caused this one." + ) class Config: """Customize configuration for this model.""" @@ -26,13 +55,16 @@ class Config: def schema_extra(schema: Dict[str, Any], model: object) -> None: """Append the schema to make the errorCode appear required. - `errorCode` has a default because it is not included in earlier + `errorCode`, `wrappedErrors`, and `errorInfo` have defaults because they are not included in earlier versions of this model, _and_ this model is loaded directly from the on-robot store. That means that, without a default, it will fail to parse. Once a default is defined, the automated schema will mark this as a non-required field, which is misleading as this is a response from the server to the client and it will always have an errorCode defined. This hack is required because it informs the client - that it does not, in fact, have to account for a missing errorCode. + that it does not, in fact, have to account for a missing errorCode, wrappedError, or errorInfo. """ - schema["required"].append("errorCode") + schema["required"].extend(["errorCode", "wrappedErrors", "errorInfo"]) + + +ErrorOccurrence.update_forward_refs() diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 31871ef2205..de8e2c4883e 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -1,20 +1,31 @@ """Protocol engine exceptions.""" -from enum import Enum, unique +from logging import getLogger +from typing import Any, Dict, Optional, Union, Iterator, Sequence +from opentrons_shared_data.errors import ErrorCodes +from opentrons_shared_data.errors.exceptions import EnumeratedError, PythonException -@unique -class ErrorCode(Enum): - """Enumerated error codes.""" +log = getLogger(__name__) - UNKNOWN = "4000" # Catch-all code for any unclassified error - -class ProtocolEngineError(RuntimeError): +class ProtocolEngineError(EnumeratedError): """Base Protocol Engine error class.""" - # This default error code should be overridden in every child class. - ERROR_CODE: str = ErrorCode.UNKNOWN.value + def __init__( + self, + code: Optional[ErrorCodes] = None, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ProtocolEngineError.""" + super().__init__( + code=code or ErrorCodes.GENERAL_ERROR, + message=message, + detail=detail, + wrapping=wrapping, + ) class UnexpectedProtocolError(ProtocolEngineError): @@ -25,12 +36,27 @@ class UnexpectedProtocolError(ProtocolEngineError): and wrapped. """ - ERROR_CODE: str = ErrorCode.UNKNOWN.value - - def __init__(self, original_error: Exception) -> None: + def __init__( + self, + message: Optional[str] = None, + wrapping: Optional[Sequence[Union[EnumeratedError, BaseException]]] = None, + ) -> None: """Initialize an UnexpectedProtocolError with an original error.""" - super().__init__(str(original_error)) - self.original_error: Exception = original_error + + def _convert_exc() -> Iterator[EnumeratedError]: + if not wrapping: + return + for exc in wrapping: + if isinstance(exc, EnumeratedError): + yield exc + else: + yield PythonException(exc) + + super().__init__( + code=ErrorCodes.GENERAL_ERROR, + message=message, + wrapping=[e for e in _convert_exc()], + ) # TODO(mc, 2020-10-18): differentiate between pipette missing vs incorrect. @@ -43,204 +69,645 @@ class FailedToLoadPipetteError(ProtocolEngineError): - A missing pipette on the requested mount """ + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a FailedToLoadPipetteError.""" + super().__init__(ErrorCodes.PIPETTE_NOT_PRESENT, message, details, wrapping) + # TODO(mc, 2020-10-18): differentiate between pipette missing vs incorrect class PipetteNotAttachedError(ProtocolEngineError): """Raised when an operation's required pipette is not attached.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PipetteNotAttachedError.""" + super().__init__(ErrorCodes.PIPETTE_NOT_PRESENT, message, details, wrapping) + class TipNotAttachedError(ProtocolEngineError): """Raised when an operation's required pipette tip is not attached.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PIpetteNotAttachedError.""" + super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, details, wrapping) + class TipAttachedError(ProtocolEngineError): """Raised when a tip shouldn't be attached, but is.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PIpetteNotAttachedError.""" + super().__init__(ErrorCodes.UNEXPECTED_TIP_ATTACH, message, details, wrapping) + class CommandDoesNotExistError(ProtocolEngineError): """Raised when referencing a command that does not exist.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a CommandDoesNotExistError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LabwareNotLoadedError(ProtocolEngineError): """Raised when referencing a labware that has not been loaded.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareNotLoadedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LabwareNotLoadedOnModuleError(ProtocolEngineError): """Raised when referencing a labware on a module that has not been loaded.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareNotLoadedOnModuleError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LabwareNotOnDeckError(ProtocolEngineError): """Raised when a labware can't be used because it's off-deck.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareNotOnDeckError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LiquidDoesNotExistError(ProtocolEngineError): """Raised when referencing a liquid that has not been loaded.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LiquidDoesNotExistError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LabwareDefinitionDoesNotExistError(ProtocolEngineError): """Raised when referencing a labware definition that does not exist.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareDefinitionDoesNotExistError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LabwareOffsetDoesNotExistError(ProtocolEngineError): """Raised when referencing a labware offset that does not exist.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareOffsetDoesNotExistError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LabwareIsNotTipRackError(ProtocolEngineError): """Raised when trying to use a regular labware as a tip rack.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareIsNotTiprackError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LabwareIsTipRackError(ProtocolEngineError): """Raised when trying to use a command not allowed on tip rack.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareIsTiprackError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class TouchTipDisabledError(ProtocolEngineError): """Raised when touch tip is used on well with touchTipDisabled quirk.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a TouchTipDisabledError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class WellDoesNotExistError(ProtocolEngineError): """Raised when referencing a well that does not exist.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a WellDoesNotExistError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class PipetteNotLoadedError(ProtocolEngineError): """Raised when referencing a pipette that has not been loaded.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PipetteNotLoadedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class ModuleNotLoadedError(ProtocolEngineError): """Raised when referencing a module that has not been loaded.""" def __init__(self, *, module_id: str) -> None: - super().__init__(f"Module {module_id} not found.") + super().__init__(ErrorCodes.GENERAL_ERROR, f"Module {module_id} not found.") class ModuleNotOnDeckError(ProtocolEngineError): """Raised when trying to use a module that is loaded off the deck.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ModuleNotOnDeckError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class ModuleNotConnectedError(ProtocolEngineError): """Raised when trying to use a module that is not connected to the robot electrically.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ModuleNotConnectedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class SlotDoesNotExistError(ProtocolEngineError): """Raised when referencing a deck slot that does not exist.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a SlotDoesNotExistError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + # TODO(mc, 2020-11-06): flesh out with structured data to replicate # existing LabwareHeightError class FailedToPlanMoveError(ProtocolEngineError): """Raised when a requested movement could not be planned.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a FailedToPlanmoveError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class MustHomeError(ProtocolEngineError): """Raised when motors must be homed due to unknown current position.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a MustHomeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class SetupCommandNotAllowedError(ProtocolEngineError): """Raised when adding a setup command to a non-idle/non-paused engine.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a SetupCommandNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class PauseNotAllowedError(ProtocolEngineError): """Raised when attempting to pause a run that is not running.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PauseNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class RunStoppedError(ProtocolEngineError): """Raised when attempting to interact with a stopped engine.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a RunStoppedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class ModuleNotAttachedError(ProtocolEngineError): """Raised when a requested module is not attached.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ModuleNotAttached.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class ModuleAlreadyPresentError(ProtocolEngineError): """Raised when a module is already present in a requested location.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ModuleAlreadyPresentError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class WrongModuleTypeError(ProtocolEngineError): """Raised when performing a module action on the wrong kind of module.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a WrongModuleTypeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class ThermocyclerNotOpenError(ProtocolEngineError): """Raised when trying to move to a labware that's in a closed Thermocycler.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ThermocyclerNotOpenError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class RobotDoorOpenError(ProtocolEngineError): """Raised when executing a protocol command when a robot door is open.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a RobotDoorOpenError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class PipetteMovementRestrictedByHeaterShakerError(ProtocolEngineError): """Raised when trying to move to labware that's restricted by a module.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PipetteMovementRestrictedByHeaterShakerError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class HeaterShakerLabwareLatchNotOpenError(ProtocolEngineError): """Raised when Heater-Shaker latch is not open when it is expected to be so.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a HeaterShakerLabwareLatchNotOpenError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class HeaterShakerLabwareLatchStatusUnknown(ProtocolEngineError): """Raised when Heater-Shaker latch has not been set before moving to it.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a HeaterShakerLabwareLatchStatusUnknown.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class EngageHeightOutOfRangeError(ProtocolEngineError): """Raised when a Magnetic Module engage height is out of bounds.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a EngageHeightOutOfRangeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class NoMagnetEngageHeightError(ProtocolEngineError): """Raised if a Magnetic Module engage height is missing.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a NoMagnetEngageHeightError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class NoTargetTemperatureSetError(ProtocolEngineError): """Raised when awaiting temperature when no target was set.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a NoTargetTemperatureSetError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class InvalidTargetTemperatureError(ProtocolEngineError): """Raised when attempting to set an invalid target temperature.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidTargetTemperatureError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class InvalidBlockVolumeError(ProtocolEngineError): """Raised when attempting to set an invalid block max volume.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidBlockVolumeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class InvalidHoldTimeError(ProtocolEngineError): """An error raised when attempting to set an invalid temperature hold time.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidHoldTimeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class InvalidTargetSpeedError(ProtocolEngineError): """Raised when attempting to set an invalid target speed.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidTargetSpeedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class CannotPerformModuleAction(ProtocolEngineError): """Raised when trying to perform an illegal hardware module action.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a CannotPerformModuleAction.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class ProtocolCommandFailedError(ProtocolEngineError): """Raised if a fatal command execution error has occurred.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ProtocolCommandFailedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class HardwareNotSupportedError(ProtocolEngineError): """Raised when executing a command on the wrong hardware.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a HardwareNotSupportedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class GripperNotAttachedError(ProtocolEngineError): """Raised when executing a gripper action without an attached gripper.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a GripperNotAttachedError.""" + super().__init__(ErrorCodes.GRIPPER_NOT_PRESENT, message, details, wrapping) + class LabwareMovementNotAllowedError(ProtocolEngineError): """Raised when attempting an illegal labware movement.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LabwareMovementNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class LocationIsOccupiedError(ProtocolEngineError): """Raised when attempting to place labware in a non-empty location.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LocationIsOccupiedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class FirmwareUpdateRequired(ProtocolEngineError): """Raised when the firmware needs to be updated.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a LocationIsOccupiedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class PipetteNotReadyToAspirateError(ProtocolEngineError): """Raised when the pipette is not ready to aspirate.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a PipetteNotReadyToAspirateError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class InvalidPipettingVolumeError(ProtocolEngineError): """Raised when pipetting a volume larger than the pipette volume.""" + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidPipettingVolumeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + class InvalidAxisForRobotType(ProtocolEngineError): """Raised when attempting to use an axis that is not present on the given type of robot.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidAxisForRobotType.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) diff --git a/api/src/opentrons/protocol_engine/execution/command_executor.py b/api/src/opentrons/protocol_engine/execution/command_executor.py index 1c9af844a20..48c93ed3b5a 100644 --- a/api/src/opentrons/protocol_engine/execution/command_executor.py +++ b/api/src/opentrons/protocol_engine/execution/command_executor.py @@ -104,7 +104,7 @@ async def execute(self, command_id: str) -> None: if isinstance(error, asyncio.CancelledError): error = RunStoppedError("Run was cancelled") elif not isinstance(error, ProtocolEngineError): - error = UnexpectedProtocolError(error) + error = UnexpectedProtocolError(message=str(error), wrapping=[error]) self._action_dispatcher.dispatch( FailCommandAction( diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index e86142ce05b..24a58149ab2 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -6,6 +6,8 @@ from datetime import datetime from typing import Dict, List, Mapping, Optional, Union +from opentrons_shared_data.errors.exceptions import EnumeratedError + from opentrons.ordered_set import OrderedSet from opentrons.hardware_control.types import DoorState @@ -32,7 +34,7 @@ SetupCommandNotAllowedError, PauseNotAllowedError, ProtocolCommandFailedError, - ProtocolEngineError, + UnexpectedProtocolError, ) from ..types import EngineStatus from .abstract_store import HasState, HandlesActions @@ -251,12 +253,8 @@ def handle_action(self, action: Action) -> None: # noqa: C901 self._state.running_command_id = None elif isinstance(action, FailCommandAction): - error_occurrence = ErrorOccurrence.construct( - id=action.error_id, - createdAt=action.failed_at, - errorType=type(action.error).__name__, - detail=str(action.error), - errorCode=action.error.ERROR_CODE, + error_occurrence = ErrorOccurrence.from_failed( + id=action.error_id, createdAt=action.failed_at, error=action.error ) prev_entry = self._state.commands_by_id[action.command_id] @@ -335,20 +333,20 @@ def handle_action(self, action: Action) -> None: # noqa: C901 if action.error_details: error_id = action.error_details.error_id created_at = action.error_details.created_at - error = action.error_details.error - - error_code = ( - error.ERROR_CODE - if isinstance(error, ProtocolEngineError) - else ProtocolEngineError.ERROR_CODE - ) - self._state.errors_by_id[error_id] = ErrorOccurrence.construct( - id=error_id, - createdAt=created_at, - errorType=type(error).__name__, - detail=str(error), - errorCode=error_code, + if not isinstance( + action.error_details.error, + EnumeratedError, + ): + error: EnumeratedError = UnexpectedProtocolError( + message=str(action.error_details.error), + wrapping=[action.error_details.error], + ) + else: + error = action.error_details.error + + self._state.errors_by_id[error_id] = ErrorOccurrence.from_failed( + id=error_id, createdAt=created_at, error=error ) elif isinstance(action, HardwareStoppedAction): diff --git a/api/src/opentrons/protocol_runner/legacy_command_mapper.py b/api/src/opentrons/protocol_runner/legacy_command_mapper.py index 2aed3ea197b..46f90f6e0ae 100644 --- a/api/src/opentrons/protocol_runner/legacy_command_mapper.py +++ b/api/src/opentrons/protocol_runner/legacy_command_mapper.py @@ -19,6 +19,7 @@ pipette_data_provider, ) from opentrons_shared_data.labware.labware_definition import LabwareDefinition +from opentrons_shared_data.errors import ErrorCodes, EnumeratedError, PythonException from opentrons.protocol_api.core.legacy.deck import FIXED_TRASH_ID from .legacy_wrappers import ( @@ -45,6 +46,22 @@ class LegacyCommandParams(pe_commands.CustomParams): class LegacyContextCommandError(ProtocolEngineError): """An error returned when a PAPIv2 ProtocolContext command fails.""" + def __init__(self, wrapping_exc: BaseException) -> None: + + if isinstance(wrapping_exc, EnumeratedError): + super().__init__( + wrapping_exc.code, + wrapping_exc.message, + wrapping_exc.detail, + wrapping_exc.wrapping, + ) + else: + super().__init__( + code=ErrorCodes.GENERAL_ERROR, + message=str(wrapping_exc), + wrapping=[PythonException(wrapping_exc)], + ) + _LEGACY_TO_PE_MODULE: Dict[LegacyModuleModel, pe_types.ModuleModel] = { LegacyMagneticModuleModel.MAGNETIC_V1: pe_types.ModuleModel.MAGNETIC_MODULE_V1, @@ -224,7 +241,7 @@ def map_command( # noqa: C901 command_id=running_command.id, error_id=ModelUtils.generate_id(), failed_at=now, - error=LegacyContextCommandError(str(command_error)), + error=LegacyContextCommandError(command_error), ) ) diff --git a/api/src/opentrons/protocols/execution/errors.py b/api/src/opentrons/protocols/execution/errors.py index c979dc1240c..84d28d84643 100644 --- a/api/src/opentrons/protocols/execution/errors.py +++ b/api/src/opentrons/protocols/execution/errors.py @@ -1,4 +1,7 @@ -class ExceptionInProtocolError(Exception): +from opentrons_shared_data.errors.exceptions import GeneralError + + +class ExceptionInProtocolError(GeneralError): """This exception wraps an exception that was raised from a protocol for proper error message formatting by the rpc, since it's only here that we can properly figure out formatting @@ -9,11 +12,14 @@ def __init__(self, original_exc, original_tb, message, line): self.original_tb = original_tb self.message = message self.line = line - super().__init__(original_exc, original_tb, message, line) + super().__init__( + wrapping=[original_exc], + message="{}{}: {}".format( + self.original_exc.__class__.__name__, + " [line {}]".format(self.line) if self.line else "", + self.message, + ), + ) def __str__(self): - return "{}{}: {}".format( - self.original_exc.__class__.__name__, - " [line {}]".format(self.line) if self.line else "", - self.message, - ) + return self.message diff --git a/api/tests/opentrons/protocol_engine/errors/test_error_occurrence.py b/api/tests/opentrons/protocol_engine/errors/test_error_occurrence.py index a0df28ed3f9..a2feb8261f7 100644 --- a/api/tests/opentrons/protocol_engine/errors/test_error_occurrence.py +++ b/api/tests/opentrons/protocol_engine/errors/test_error_occurrence.py @@ -11,8 +11,9 @@ def test_error_occurrence_schema() -> None: This is explicitly tested because we are overriding the schema due to a default value for errorCode. """ - required_items: List[str] = ErrorOccurrence.schema()["required"] - + required_items: List[str] = ErrorOccurrence.schema()["definitions"][ + "ErrorOccurrence" + ]["required"] assert "errorCode" in required_items diff --git a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py index 2d2986da0d4..6109a4ad765 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_command_executor.py +++ b/api/tests/opentrons/protocol_engine/execution/test_command_executor.py @@ -264,7 +264,7 @@ def _ImplementationCls(self) -> Type[_TestCommandImpl]: ["command_error", "expected_error"], [ ( - errors.ProtocolEngineError("oh no"), + errors.ProtocolEngineError(message="oh no"), matchers.ErrorMatching(errors.ProtocolEngineError, match="oh no"), ), ( diff --git a/api/tests/opentrons/protocol_engine/state/test_command_store.py b/api/tests/opentrons/protocol_engine/state/test_command_store.py index 732bd60bfcd..3147d8dc5b7 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_store.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import NamedTuple, Type +from opentrons_shared_data.errors import ErrorCodes from opentrons.ordered_set import OrderedSet from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons.types import MountType, DeckSlotName @@ -455,7 +456,7 @@ def test_command_failure_clears_queues() -> None: command_id="command-id-1", error_id="error-id", failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError("oh no"), + error=errors.ProtocolEngineError(message="oh no"), ) expected_failed_1 = commands.WaitForResume( @@ -466,7 +467,7 @@ def test_command_failure_clears_queues() -> None: errorType="ProtocolEngineError", detail="oh no", createdAt=datetime(year=2023, month=3, day=3), - errorCode=errors.ProtocolEngineError.ERROR_CODE, + errorCode=ErrorCodes.GENERAL_ERROR.value.code, ), createdAt=datetime(year=2021, month=1, day=1), startedAt=datetime(year=2022, month=2, day=2), @@ -557,7 +558,7 @@ def test_setup_command_failure_only_clears_setup_command_queue() -> None: command_id="command-id-2", error_id="error-id", failed_at=datetime(year=2023, month=3, day=3), - error=errors.ProtocolEngineError("oh no"), + error=errors.ProtocolEngineError(message="oh no"), ) expected_failed_cmd_2 = commands.WaitForResume( id="command-id-2", @@ -567,7 +568,7 @@ def test_setup_command_failure_only_clears_setup_command_queue() -> None: errorType="ProtocolEngineError", detail="oh no", createdAt=datetime(year=2023, month=3, day=3), - errorCode=errors.ProtocolEngineError.ERROR_CODE, + errorCode=ErrorCodes.GENERAL_ERROR.value.code, ), createdAt=datetime(year=2021, month=1, day=1), startedAt=datetime(year=2022, month=2, day=2), @@ -819,10 +820,29 @@ def test_command_store_saves_unknown_finish_error() -> None: "error-id": errors.ErrorOccurrence( id="error-id", createdAt=datetime(year=2021, month=1, day=1), - errorType="RuntimeError", + # this is wrapped into an UnexpectedProtocolError because it's not + # enumerated + errorType="UnexpectedProtocolError", + # but it has the information about what created it detail="oh no", # Unknown errors use the default error code - errorCode=errors.ProtocolEngineError.ERROR_CODE, + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + # and they wrap + wrappedErrors=[ + errors.ErrorOccurrence( + id="error-id", + createdAt=datetime(year=2021, month=1, day=1), + errorType="PythonException", + detail="RuntimeError: oh no\n", + errorCode="4000", + # and we get some fun extra info if this wraps a normal exception + errorInfo={ + "class": "RuntimeError", + "args": "('oh no',)", + }, + wrappedErrors=[], + ) + ], ) }, run_started_at=None, @@ -832,15 +852,15 @@ def test_command_store_saves_unknown_finish_error() -> None: def test_command_store_saves_correct_error_code() -> None: """If an error is derived from ProtocolEngineError, its ErrorCode should be used.""" - TEST_CODE = "1234" class MyCustomError(errors.ProtocolEngineError): - ERROR_CODE = TEST_CODE + def __init__(self, message: str) -> None: + super().__init__(ErrorCodes.PIPETTE_NOT_PRESENT, message) subject = CommandStore(is_door_open=False, config=_make_config()) error_details = FinishErrorDetails( - error=MyCustomError("oh no"), + error=MyCustomError(message="oh no"), error_id="error-id", created_at=datetime(year=2021, month=1, day=1), ) @@ -862,7 +882,7 @@ class MyCustomError(errors.ProtocolEngineError): createdAt=datetime(year=2021, month=1, day=1), errorType="MyCustomError", detail="oh no", - errorCode=TEST_CODE, + errorCode=ErrorCodes.PIPETTE_NOT_PRESENT.value.code, ) }, run_started_at=None, @@ -927,7 +947,7 @@ def test_command_store_handles_command_failed() -> None: errorType="ProtocolEngineError", createdAt=datetime(year=2022, month=2, day=2), detail="oh no", - errorCode=errors.ProtocolEngineError.ERROR_CODE, + errorCode=ErrorCodes.GENERAL_ERROR.value.code, ) expected_failed_command = create_failed_command( @@ -943,7 +963,7 @@ def test_command_store_handles_command_failed() -> None: command_id="command-id", error_id="error-id", failed_at=datetime(year=2022, month=2, day=2), - error=errors.ProtocolEngineError("oh no"), + error=errors.ProtocolEngineError(message="oh no"), ) ) diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml index 808569c9ed1..a171c65efd3 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_run_failure.tavern.yaml @@ -111,6 +111,8 @@ stages: createdAt: !anystr detail: 'Cannot perform DROPTIP without a tip attached' errorCode: '4000' + errorInfo: !anydict + wrappedErrors: !anylist params: pipetteId: pipetteId labwareId: tipRackId diff --git a/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml index 6dc7dd719ce..40ca3cd7462 100644 --- a/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_papi_v2_run_failure.tavern.yaml @@ -110,6 +110,8 @@ stages: createdAt: !anystr detail: 'Cannot perform DROPTIP without a tip attached' errorCode: '4000' + errorInfo: !anydict + wrappedErrors: !anylist params: pipetteId: !anystr labwareId: !anystr diff --git a/robot-server/tests/integration/http_api/runs/test_pause_run_not_started.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_pause_run_not_started.tavern.yaml index d3dc5f8c8cd..8c17e5efdaa 100644 --- a/robot-server/tests/integration/http_api/runs/test_pause_run_not_started.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_pause_run_not_started.tavern.yaml @@ -36,4 +36,4 @@ stages: errors: - id: 'RunActionNotAllowed' title: 'Run Action Not Allowed' - detail: 'Cannot pause a run that is not running.' + detail: 'Error 4000 GENERAL_ERROR (PauseNotAllowedError): Cannot pause a run that is not running.' diff --git a/robot-server/tests/runs/router/test_commands_router.py b/robot-server/tests/runs/router/test_commands_router.py index 07d975daec7..21c3d63a56d 100644 --- a/robot-server/tests/runs/router/test_commands_router.py +++ b/robot-server/tests/runs/router/test_commands_router.py @@ -213,7 +213,9 @@ async def test_add_conflicting_setup_command( ) assert exc_info.value.status_code == 409 - assert exc_info.value.content["errors"][0]["detail"] == "oh no" + assert exc_info.value.content["errors"][0]["detail"] == matchers.StringMatching( + ".*4000.*oh no" + ) async def test_add_command_to_stopped_engine( @@ -238,7 +240,9 @@ async def test_add_command_to_stopped_engine( ) assert exc_info.value.status_code == 409 - assert exc_info.value.content["errors"][0]["detail"] == "oh no" + assert exc_info.value.content["errors"][0]["detail"] == matchers.StringMatching( + ".*4000.*oh no" + ) async def test_get_run_commands( diff --git a/shared-data/errors/definitions/1/errors.json b/shared-data/errors/definitions/1/errors.json index 044d0965ed5..ff0f57d2b24 100644 --- a/shared-data/errors/definitions/1/errors.json +++ b/shared-data/errors/definitions/1/errors.json @@ -106,6 +106,10 @@ "detail": "Gripper Not Present", "category": "roboticsInteractionError" }, + "3012": { + "detail": "Unexpected Tip Presence", + "category": "roboticsInteractionError" + }, "4000": { "detail": "Unknown or Uncategorized Error", "category": "generalError" diff --git a/shared-data/python/opentrons_shared_data/errors/__init__.py b/shared-data/python/opentrons_shared_data/errors/__init__.py index 03f58ba8d1e..324cfbfa623 100644 --- a/shared-data/python/opentrons_shared_data/errors/__init__.py +++ b/shared-data/python/opentrons_shared_data/errors/__init__.py @@ -9,7 +9,13 @@ from .categories import ErrorCategories, ErrorCategory from .codes import ErrorCodes, ErrorCode -from .exceptions import EnumeratedError +from .exceptions import ( + EnumeratedError, + PythonException, + GeneralError, + RoboticsControlError, + RoboticsInteractionError, +) __all__ = [ "ErrorCategory", @@ -17,4 +23,8 @@ "ErrorCode", "ErrorCodes", "EnumeratedError", + "PythonException", + "GeneralError", + "RoboticsControlError", + "RoboticsInteractionError", ] diff --git a/shared-data/python/opentrons_shared_data/errors/codes.py b/shared-data/python/opentrons_shared_data/errors/codes.py index 04822abcae6..f8c4d46d075 100644 --- a/shared-data/python/opentrons_shared_data/errors/codes.py +++ b/shared-data/python/opentrons_shared_data/errors/codes.py @@ -56,6 +56,7 @@ class ErrorCodes(Enum): E_STOP_NOT_PRESENT = _code_from_dict_entry("3009") PIPETTE_NOT_PRESENT = _code_from_dict_entry("3010") GRIPPER_NOT_PRESENT = _code_from_dict_entry("3011") + UNEXPECTED_TIP_ATTACH = _code_from_dict_entry("3012") GENERAL_ERROR = _code_from_dict_entry("4000") @classmethod diff --git a/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index 8b1d4e30b3f..cf3c1e6752d 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -1,6 +1,9 @@ """Exception hierarchy for error codes.""" -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List, Iterator, Union, Sequence from logging import getLogger +from traceback import format_exception_only, format_tb +import inspect +import sys from .codes import ErrorCodes from .categories import ErrorCategories @@ -17,16 +20,22 @@ def __init__( code: ErrorCodes, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence["EnumeratedError"]] = None, ) -> None: """Build an EnumeratedError.""" self.code = code self.message = message or "" self.detail = detail or {} + self.wrapping = wrapping or [] def __repr__(self) -> str: """Get a representative string for the exception.""" return f"<{self.__class__.__name__}: code=<{self.code.value.code} {self.code.name}> message={self.message} detail={str(self.detail)}" + def __str__(self) -> str: + """Get a human-readable string.""" + return f'Error {self.code.value.code} {self.code.name} ({self.__class__.__name__}){f": {self.message}" if self.message else ""}' + class CommunicationError(EnumeratedError): """An exception indicating an unknown communications error. @@ -41,6 +50,7 @@ def __init__( code: Optional[ErrorCodes] = None, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a CommunicationError.""" if code and code not in code.of_category( @@ -49,7 +59,9 @@ def __init__( log.error( f"Error {code.name} is inappropriate for a CommunicationError exception" ) - super().__init__(code or ErrorCodes.COMMUNICATION_ERROR, message, detail) + super().__init__( + code or ErrorCodes.COMMUNICATION_ERROR, message, detail, wrapping + ) class RoboticsControlError(EnumeratedError): @@ -65,6 +77,7 @@ def __init__( code: Optional[ErrorCodes] = None, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a RoboticsControlError.""" if code and code not in code.of_category( @@ -74,7 +87,9 @@ def __init__( f"Error {code.name} is inappropriate for a RoboticsControlError exception" ) - super().__init__(code or ErrorCodes.ROBOTICS_CONTROL_ERROR, message, detail) + super().__init__( + code or ErrorCodes.ROBOTICS_CONTROL_ERROR, message, detail, wrapping + ) class RoboticsInteractionError(EnumeratedError): @@ -90,6 +105,7 @@ def __init__( code: Optional[ErrorCodes] = None, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a RoboticsInteractionError.""" if code and code not in code.of_category( @@ -99,7 +115,9 @@ def __init__( f"Error {code.name} is inappropriate for a RoboticsInteractionError exception" ) - super().__init__(code or ErrorCodes.ROBOTICS_INTERACTION_ERROR, message, detail) + super().__init__( + code or ErrorCodes.ROBOTICS_INTERACTION_ERROR, message, detail, wrapping + ) class GeneralError(EnumeratedError): @@ -115,190 +133,325 @@ def __init__( code: Optional[ErrorCodes] = None, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[Union[EnumeratedError, BaseException]]] = None, ) -> None: """Build a GeneralError.""" if code and code not in code.of_category(ErrorCategories.GENERAL_ERROR): log.error( f"Error {code.name} is inappropriate for a GeneralError exception" ) - super().__init__(code or ErrorCodes.GENERAL_ERROR, message, detail) + + def _wrapped_excs() -> Iterator[EnumeratedError]: + if not wrapping: + return + for exc in wrapping: + if isinstance(exc, EnumeratedError): + yield exc + else: + yield PythonException(exc) + + super().__init__( + code or ErrorCodes.GENERAL_ERROR, message, detail, list(_wrapped_excs()) + ) + + +def _exc_harvest_predicate(v: Any) -> bool: + if inspect.isroutine(v): + return False + if inspect.ismethoddescriptor(v): + return False + # on python 3.11 and up we can check if things are method wrappers, which basic builtin + # dunders like __add__ are, but until then we can't and also don't know this is real + if sys.version_info.minor >= 11 and inspect.ismethodwrapper(v): # type: ignore[attr-defined] + return False + return True + + +class PythonException(GeneralError): + """An exception wrapping a base exception but with a GeneralError code and storing details.""" + + def __init__(self, exc: BaseException) -> None: + """Build a PythonException.""" + + def _descend_exc_ctx(exc: BaseException) -> List[PythonException]: + descendants: List[PythonException] = [] + if exc.__context__: + descendants.append(PythonException(exc.__context__)) + if exc.__cause__: + descendants.append(PythonException(exc.__cause__)) + return descendants + + base_details = { + k: str(v) + for k, v in inspect.getmembers(exc, _exc_harvest_predicate) + if not k.startswith("_") + } + try: + tb = exc.__traceback__ + except AttributeError: + tb = None + + if tb: + base_details["traceback"] = "\n".join(format_tb(tb)) + base_details["class"] = type(exc).__name__ + + super().__init__( + message="\n".join(format_exception_only(type(exc), exc)), + detail=base_details, + wrapping=_descend_exc_ctx(exc), + ) class CanbusCommunicationError(CommunicationError): """An error indicating a problem with canbus communication.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a CanbusCommunicationError.""" - super().__init__(ErrorCodes.CANBUS_COMMUNICATION_ERROR, message, detail) + super().__init__( + ErrorCodes.CANBUS_COMMUNICATION_ERROR, message, detail, wrapping + ) class InternalUSBCommunicationError(CommunicationError): """An error indicating a problem with internal USB communication - e.g. with the rear panel.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an InternalUSBCommunicationError.""" - super().__init__(ErrorCodes.INTERNAL_USB_COMMUNICATION_ERROR, message, detail) + super().__init__( + ErrorCodes.INTERNAL_USB_COMMUNICATION_ERROR, message, detail, wrapping + ) class ModuleCommunicationError(CommunicationError): """An error indicating a problem with module communication.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build an ModuleCommunicationError.""" - super().__init__(ErrorCodes.MODULE_COMMUNICATION_ERROR, message, detail) + """Build a CanbusCommunicationError.""" + super().__init__( + ErrorCodes.CANBUS_COMMUNICATION_ERROR, message, detail, wrapping + ) class CommandTimedOutError(CommunicationError): """An error indicating that a command timed out.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a CommandTimedOutError.""" - super().__init__(ErrorCodes.COMMAND_TIMED_OUT, message, detail) + super().__init__(ErrorCodes.COMMAND_TIMED_OUT, message, detail, wrapping) class FirmwareUpdateFailedError(CommunicationError): """An error indicating that a firmware update failed.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a FirmwareUpdateFailedError.""" - super().__init__(ErrorCodes.FIRMWARE_UPDATE_FAILED, message, detail) + super().__init__(ErrorCodes.FIRMWARE_UPDATE_FAILED, message, detail, wrapping) class MotionFailedError(RoboticsControlError): """An error indicating that a motion failed.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a FirmwareUpdateFailedError.""" - super().__init__(ErrorCodes.MOTION_FAILED, message, detail) + super().__init__(ErrorCodes.MOTION_FAILED, message, detail, wrapping) class HomingFailedError(RoboticsControlError): """An error indicating that a homing failed.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a FirmwareUpdateFailedError.""" - super().__init__(ErrorCodes.HOMING_FAILED, message, detail) + super().__init__(ErrorCodes.HOMING_FAILED, message, detail, wrapping) class StallOrCollisionDetectedError(RoboticsControlError): """An error indicating that a stall or collision occurred.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a StallOrCollisionDetectedError.""" - super().__init__(ErrorCodes.STALL_OR_COLLISION_DETECTED, message, detail) + super().__init__( + ErrorCodes.STALL_OR_COLLISION_DETECTED, message, detail, wrapping + ) class MotionPlanningFailureError(RoboticsControlError): """An error indicating that motion planning failed.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a MotionPlanningFailureError.""" - super().__init__(ErrorCodes.MOTION_PLANNING_FAILURE, message, detail) + super().__init__(ErrorCodes.MOTION_PLANNING_FAILURE, message, detail, wrapping) class LabwareDroppedError(RoboticsInteractionError): """An error indicating that the gripper dropped labware it was holding.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a LabwareDroppedError.""" - super().__init__(ErrorCodes.LABWARE_DROPPED, message, detail) + super().__init__(ErrorCodes.LABWARE_DROPPED, message, detail, wrapping) class TipPickupFailedError(RoboticsInteractionError): """An error indicating that a pipette failed to pick up a tip.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a TipPickupFailedError.""" - super().__init__(ErrorCodes.TIP_PICKUP_FAILED, message, detail) + super().__init__(ErrorCodes.TIP_PICKUP_FAILED, message, detail, wrapping) class TipDropFailedError(RoboticsInteractionError): """An error indicating that a pipette failed to drop a tip.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build a TipPickupFailedError.""" - super().__init__(ErrorCodes.TIP_DROP_FAILED, message, detail) + super().__init__(ErrorCodes.TIP_DROP_FAILED, message, detail, wrapping) class UnexpectedTipRemovalError(RoboticsInteractionError): """An error indicating that a pipette did not have a tip when it should (aka it fell off).""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an UnexpectedTipRemovalError.""" - super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, detail) + super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, detail, wrapping) + + +class UnexpectedTipAttachError(RoboticsInteractionError): + """An error indicating that a pipette had a tip when it shouldn't.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an UnexpectedTipAttachError.""" + super().__init__(ErrorCodes.UNEXPECTED_TIP_ATTACH, message, detail, wrapping) class PipetteOverpressureError(RoboticsInteractionError): """An error indicating that a pipette experienced an overpressure event, likely because of a clog.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an PipetteOverpressureError.""" - super().__init__(ErrorCodes.PIPETTE_OVERPRESSURE, message, detail) + super().__init__(ErrorCodes.PIPETTE_OVERPRESSURE, message, detail, wrapping) class EStopActivatedError(RoboticsInteractionError): """An error indicating that the E-stop was activated.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an EStopActivatedError.""" - super().__init__(ErrorCodes.E_STOP_ACTIVATED, message, detail) + super().__init__(ErrorCodes.E_STOP_ACTIVATED, message, detail, wrapping) class EStopNotPresentError(RoboticsInteractionError): """An error indicating that the E-stop is not present.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an EStopNotPresentError.""" - super().__init__(ErrorCodes.E_STOP_NOT_PRESENT, message, detail) + super().__init__(ErrorCodes.E_STOP_NOT_PRESENT, message, detail, wrapping) class PipetteNotPresentError(RoboticsInteractionError): """An error indicating that the specified pipette is not present.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an PipetteNotPresentError.""" - super().__init__(ErrorCodes.PIPETTE_NOT_PRESENT, message, detail) + super().__init__(ErrorCodes.PIPETTE_NOT_PRESENT, message, detail, wrapping) class GripperNotPresentError(RoboticsInteractionError): """An error indicating that the specified gripper is not present.""" def __init__( - self, message: Optional[str] = None, detail: Optional[Dict[str, Any]] = None + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: """Build an GripperNotPresentError.""" - super().__init__(ErrorCodes.GRIPPER_NOT_PRESENT, message, detail) + super().__init__(ErrorCodes.GRIPPER_NOT_PRESENT, message, detail, wrapping) diff --git a/shared-data/python/tests/errors/test_exceptions.py b/shared-data/python/tests/errors/test_exceptions.py new file mode 100644 index 00000000000..04552c3c672 --- /dev/null +++ b/shared-data/python/tests/errors/test_exceptions.py @@ -0,0 +1,74 @@ +"""Test exception handling.""" +from opentrons_shared_data.errors.exceptions import PythonException + + +def test_exception_wrapping_single_level() -> None: + """When we make a PythonException it should capture details of an exception.""" + try: + raise RuntimeError("oh no!") + except RuntimeError as e: + captured_exc = e + + wrapped = PythonException(captured_exc) + assert wrapped.detail["class"] == "RuntimeError" + assert wrapped.detail["traceback"] + assert wrapped.detail["args"] == "('oh no!',)" + + +def test_exception_wrapping_multi_level() -> None: + """We can wrap all exceptions in a raise-from chain.""" + + def _raise_inner() -> None: + raise KeyError("oh no!") + + try: + try: + _raise_inner() + except KeyError as e: + raise RuntimeError("uh oh!") from e + except RuntimeError as e: + captured_exc = e + + wrapped = PythonException(captured_exc) + assert wrapped.detail["class"] == "RuntimeError" + assert wrapped.detail["traceback"] + assert wrapped.detail["args"] == "('uh oh!',)" + second = wrapped.wrapping[0] + assert isinstance(second, PythonException) + assert second.detail["class"] == "KeyError" + assert second.detail["args"] == "('oh no!',)" + assert not second.wrapping + + +def test_exception_wrapping_rethrow() -> None: + """We can wrap raise-froms.""" + try: + try: + raise RuntimeError("oh no!") + except RuntimeError as e: + raise PythonException(e) from e + except PythonException as pe: + wrapped = pe + + assert wrapped.detail["class"] == "RuntimeError" + assert wrapped.detail["traceback"] + assert wrapped.detail["args"] == "('oh no!',)" + + +def test_exception_wrapping_error_in_except() -> None: + """We can even wrap exceptions in exception handlers.""" + try: + try: + raise RuntimeError("oh no!") + except RuntimeError: + raise KeyError("uh oh") + except BaseException as e: + wrapped = PythonException(e) + + # when we have an exception-during-handler, the originally-handled + # exception is what's bubbled up: + assert wrapped.detail["class"] == "KeyError" + assert len(wrapped.wrapping) == 1 + assert wrapped.wrapping[0].detail["class"] == "RuntimeError" + assert wrapped.wrapping[0].detail["traceback"] + assert wrapped.wrapping[0].detail["args"] == "('oh no!',)"