From deb5fd7f7dcbec89e078ce72d0095b40fbbf1384 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 21 Jun 2023 17:53:51 -0400 Subject: [PATCH 1/8] feat(shared-data): add FirmwareUpdateRequired error --- shared-data/errors/definitions/1/errors.json | 4 ++++ .../python/opentrons_shared_data/errors/codes.py | 1 + .../opentrons_shared_data/errors/exceptions.py | 13 +++++++++++++ 3 files changed, 18 insertions(+) diff --git a/shared-data/errors/definitions/1/errors.json b/shared-data/errors/definitions/1/errors.json index ff0f57d2b24..fbf04541e42 100644 --- a/shared-data/errors/definitions/1/errors.json +++ b/shared-data/errors/definitions/1/errors.json @@ -110,6 +110,10 @@ "detail": "Unexpected Tip Presence", "category": "roboticsInteractionError" }, + "3013": { + "detail": "Firmware Update Required", + "category": "roboticsInteractionError" + }, "4000": { "detail": "Unknown or Uncategorized Error", "category": "generalError" diff --git a/shared-data/python/opentrons_shared_data/errors/codes.py b/shared-data/python/opentrons_shared_data/errors/codes.py index f8c4d46d075..8185c9cc8d9 100644 --- a/shared-data/python/opentrons_shared_data/errors/codes.py +++ b/shared-data/python/opentrons_shared_data/errors/codes.py @@ -57,6 +57,7 @@ class ErrorCodes(Enum): PIPETTE_NOT_PRESENT = _code_from_dict_entry("3010") GRIPPER_NOT_PRESENT = _code_from_dict_entry("3011") UNEXPECTED_TIP_ATTACH = _code_from_dict_entry("3012") + FIRMWARE_UPDATE_REQUIRED = _code_from_dict_entry("3013") 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 cf3c1e6752d..c32d22a52b4 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -392,6 +392,19 @@ def __init__( super().__init__(ErrorCodes.UNEXPECTED_TIP_ATTACH, message, detail, wrapping) +class FirmwareUpdateRequiredError(RoboticsInteractionError): + """An error indicating that a firmware update is required.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a FirmwareUpdateRequiredError.""" + super().__init__(ErrorCodes.FIRMWARE_UPDATE_REQUIRED, message, detail, wrapping) + + class PipetteOverpressureError(RoboticsInteractionError): """An error indicating that a pipette experienced an overpressure event, likely because of a clog.""" From 7f32f3661a3331354de0afd5dab20454f6e3c71c Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 21 Jun 2023 17:54:25 -0400 Subject: [PATCH 2/8] feat(robot-server): add global error codes By adding codes to the response models we can make sure they're always expressed by all the random overriding models --- robot-server/robot_server/errors/error_responses.py | 10 +++++++++- .../robot_server/errors/exception_handlers.py | 11 ++++++++++- robot-server/robot_server/errors/global_errors.py | 6 ++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/robot-server/robot_server/errors/error_responses.py b/robot-server/robot_server/errors/error_responses.py index 4ab188aff75..0ec3f177893 100644 --- a/robot-server/robot_server/errors/error_responses.py +++ b/robot-server/robot_server/errors/error_responses.py @@ -4,9 +4,10 @@ from typing import Any, Dict, Generic, Optional, Sequence, Tuple, TypeVar from robot_server.service.json_api import BaseResponseBody, ResourceLinks +from opentrons_shared_data.errors import ErrorCodes, EnumeratedError -class ApiError(Exception): +class ApiError(EnumeratedError): """An exception to throw when an endpoint should respond with an error.""" def __init__(self, status_code: int, content: Dict[str, Any]) -> None: @@ -105,6 +106,10 @@ def get_some_model(): "occurrence of the error" ), ) + errorCode: str = Field( + ..., + description=("The Opentrons error code associated with the error"), + ) def as_error(self, status_code: int) -> ApiError: """Serial this ErrorDetails as an ApiError from an ErrorResponse.""" @@ -121,6 +126,9 @@ class LegacyErrorResponse(BaseErrorBody): ..., description="A human-readable message describing the error.", ) + errorCode: str = Field( + ..., description="The Opentrons error code associated with the error" + ) class ErrorBody(BaseErrorBody, GenericModel, Generic[ErrorDetailsT]): diff --git a/robot-server/robot_server/errors/exception_handlers.py b/robot-server/robot_server/errors/exception_handlers.py index cf4a8a558ea..b1ae071c3d3 100644 --- a/robot-server/robot_server/errors/exception_handlers.py +++ b/robot-server/robot_server/errors/exception_handlers.py @@ -38,6 +38,13 @@ log = getLogger(__name__) +def _code_or_default(exc: BaseException) -> str: + if isinstance(exc, EnumeratedError): + return exc.error_code + else: + return ErrorCodes.GENERAL_ERROR.value.code + + def _route_is_legacy(request: Request) -> bool: """Check if router handling the request is a legacy v1 endpoint.""" router = request.scope.get("router") @@ -140,7 +147,9 @@ async def handle_unexpected_error(request: Request, error: Exception) -> JSONRes ).strip() if _route_is_legacy(request): - response: BaseErrorBody = LegacyErrorResponse(message=detail) + response: BaseErrorBody = LegacyErrorResponse( + message=detail, errorCode=_code_or_default(error) + ) else: response = UnexpectedError(detail=detail, meta={"stacktrace": stacktrace}) diff --git a/robot-server/robot_server/errors/global_errors.py b/robot-server/robot_server/errors/global_errors.py index 3af6b24ddd9..07b47fa2d77 100644 --- a/robot-server/robot_server/errors/global_errors.py +++ b/robot-server/robot_server/errors/global_errors.py @@ -2,6 +2,7 @@ from typing_extensions import Literal from .error_responses import ErrorDetails +from opentrons_shared_data.errors import ErrorCodes class UnexpectedError(ErrorDetails): @@ -9,6 +10,7 @@ class UnexpectedError(ErrorDetails): id: Literal["UnexpectedError"] = "UnexpectedError" title: str = "Unexpected Internal Error" + errorCode: str = ErrorCodes.GENERAL_ERROR.value.code class BadRequest(ErrorDetails): @@ -16,6 +18,7 @@ class BadRequest(ErrorDetails): id: Literal["BadRequest"] = "BadRequest" title: str = "Bad Request" + errorCode: str = ErrorCodes.GENERAL_ERROR.value.code class InvalidRequest(ErrorDetails): @@ -23,6 +26,7 @@ class InvalidRequest(ErrorDetails): id: Literal["InvalidRequest"] = "InvalidRequest" title: str = "Invalid Request" + errorCode: str = ErrorCodes.GENERAL_ERROR.value.code class IDNotFound(ErrorDetails): @@ -30,6 +34,7 @@ class IDNotFound(ErrorDetails): id: Literal["IDNotFound"] = "IDNotFound" title: str = "ID Not Found" + errorCode: str = ErrorCodes.GENERAL_ERROR.value.code class FirmwareUpdateRequired(ErrorDetails): @@ -37,3 +42,4 @@ class FirmwareUpdateRequired(ErrorDetails): id: Literal["FirmwareUpdateRequired"] = "FirmwareUpdateRequired" title: str = "Firmware Update Required" + errorCode: str = ErrorCodes.FIRMWARE_UPDATE_REQUIRED.value.code From d7163e25d2ba53fe3e8c80438d9edd8029ce5519 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 22 Jun 2023 17:05:01 -0400 Subject: [PATCH 3/8] shared-data: add more useful errors --- shared-data/errors/definitions/1/errors.json | 16 +++++ .../opentrons_shared_data/errors/codes.py | 4 ++ .../errors/exceptions.py | 69 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/shared-data/errors/definitions/1/errors.json b/shared-data/errors/definitions/1/errors.json index fbf04541e42..4a8a5c3e683 100644 --- a/shared-data/errors/definitions/1/errors.json +++ b/shared-data/errors/definitions/1/errors.json @@ -114,9 +114,25 @@ "detail": "Firmware Update Required", "category": "roboticsInteractionError" }, + "3014": { + "detail": "Invalid Actuator", + "category": "roboticsInteractionError" + }, + "3015": { + "detail": "Module Not Present", + "category": "roboticsInteractionError" + }, "4000": { "detail": "Unknown or Uncategorized Error", "category": "generalError" + }, + "4001": { + "detail": "Robot In Use", + "category": "generalError" + }, + "4002": { + "detail": "API Removed", + "category": "generalError" } } } diff --git a/shared-data/python/opentrons_shared_data/errors/codes.py b/shared-data/python/opentrons_shared_data/errors/codes.py index 8185c9cc8d9..99ec11f9537 100644 --- a/shared-data/python/opentrons_shared_data/errors/codes.py +++ b/shared-data/python/opentrons_shared_data/errors/codes.py @@ -58,7 +58,11 @@ class ErrorCodes(Enum): GRIPPER_NOT_PRESENT = _code_from_dict_entry("3011") UNEXPECTED_TIP_ATTACH = _code_from_dict_entry("3012") FIRMWARE_UPDATE_REQUIRED = _code_from_dict_entry("3013") + INVALID_ACTUATOR = _code_from_dict_entry("3014") + MODULE_NOT_PRESENT = _code_from_dict_entry("3015") GENERAL_ERROR = _code_from_dict_entry("4000") + ROBOT_IN_USE = _code_from_dict_entry("4001") + API_REMOVED = _code_from_dict_entry("4002") @classmethod @lru_cache(25) diff --git a/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index c32d22a52b4..501f4eccf9f 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -202,6 +202,19 @@ def _descend_exc_ctx(exc: BaseException) -> List[PythonException]: ) +class RobotInUseError(CommunicationError): + """An error indicating that an action cannot proceed because another is in progress.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a CanbusCommunicationError.""" + super().__init__(ErrorCodes.ROBOT_IN_USE, message, detail, wrapping) + + class CanbusCommunicationError(CommunicationError): """An error indicating a problem with canbus communication.""" @@ -468,3 +481,59 @@ def __init__( ) -> None: """Build an GripperNotPresentError.""" super().__init__(ErrorCodes.GRIPPER_NOT_PRESENT, message, detail, wrapping) + + +class InvalidActuator(RoboticsInteractionError): + """An error indicating that a specified actuator is not valid.""" + + def __init__( + self, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an GripperNotPresentError.""" + super().__init__(ErrorCodes.INVALID_ACTUATOR, message, detail, wrapping) + + +class ModuleNotPresent(RoboticsInteractionError): + """An error indicating that a specific module was not present.""" + + def __init__( + self, + identifier: str, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ModuleNotPresentError.""" + checked_detail: Dict[str, Any] = detail or {} + checked_detail["identifier"] = identifier + checked_message = message or f"Module {identifier} is not present" + super().__init__( + ErrorCodes.MODULE_NOT_PRESENT, checked_message, checked_detail, wrapping + ) + + +class APIRemoved(GeneralError): + """An error indicating that a specific API is no longer available.""" + + def __init__( + self, + api_element: str, + since_version: str, + message: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build an APIRemoved error.""" + checked_detail: Dict[str, Any] = detail or {} + checked_detail["identifier"] = api_element + checked_detail["since_version"] = since_version + checked_message = ( + message + or f"{api_element} is no longer available since version {since_version}." + ) + super().__init__( + ErrorCodes.API_REMOVED, checked_message, checked_detail, wrapping + ) From ab7290af354e30e1f32f6429fdef765caa324af4 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 22 Jun 2023 17:05:20 -0400 Subject: [PATCH 4/8] feat(robot-server): add error codes to responses We can now express error codes in top level error responses and handle and format them from the eneumerated errors that the rest of the system will soon throw. There's some interesting detail there around the MultiErrorResponse and the ErrorResponse but with multiple errors captured inside of it... I'm not sure what the right play is, but this is working out for me. There are some response format changes for some of the messages; we can fix these by making the exceptions that are being turned into the error responses EnumeratedErrors that are properly formatted. --- .../commands/get_default_engine.py | 5 +- robot-server/robot_server/commands/router.py | 4 +- .../robot_server/errors/error_responses.py | 62 +++++++++++++++++-- .../robot_server/errors/exception_handlers.py | 45 ++++++++------ .../robot_server/errors/global_errors.py | 20 ++++++ .../robot_server/robot/calibration/errors.py | 9 +++ .../runs/router/actions_router.py | 6 +- .../robot_server/runs/router/base_router.py | 6 ++ .../runs/router/commands_router.py | 10 +-- .../robot_server/runs/run_controller.py | 5 +- robot-server/robot_server/runs/run_models.py | 7 ++- robot-server/robot_server/service/errors.py | 18 +++++- .../robot_server/service/labware/router.py | 2 + .../service/legacy/routers/control.py | 12 ++-- .../service/legacy/routers/modules.py | 39 +++++++----- .../service/legacy/routers/motors.py | 8 ++- .../service/legacy/routers/networking.py | 16 ++--- .../service/legacy/routers/settings.py | 30 +++++---- .../tests/errors/test_error_responses.py | 5 +- .../tests/errors/test_exception_handlers.py | 27 ++++++-- .../test_pause_run_not_started.tavern.yaml | 4 +- .../system/test_system_time.tavern.yaml | 2 + .../integration/test_identify.tavern.yaml | 1 + ...est_labware_calibration_access.tavern.yaml | 5 ++ .../integration/test_settings.tavern.yaml | 3 +- .../test_settings_log_level.tavern.yaml | 2 + .../test_settings_pipettes.tavern.yaml | 2 + .../test_settings_reset_options.tavern.yaml | 2 + .../tests/runs/router/test_actions_router.py | 2 +- .../tests/runs/router/test_commands_router.py | 6 +- .../service/legacy/routers/test_control.py | 3 +- .../service/legacy/routers/test_modules.py | 16 +++-- .../service/legacy/routers/test_networking.py | 11 +++- .../service/legacy/routers/test_settings.py | 26 +++++--- .../tests/service/session/test_router.py | 6 ++ .../tip_length/test_tip_length_management.py | 1 + .../tests/system/test_system_router.py | 2 + 37 files changed, 321 insertions(+), 109 deletions(-) diff --git a/robot-server/robot_server/commands/get_default_engine.py b/robot-server/robot_server/commands/get_default_engine.py index ef72e137e3a..80714806285 100644 --- a/robot-server/robot_server/commands/get_default_engine.py +++ b/robot-server/robot_server/commands/get_default_engine.py @@ -6,6 +6,8 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.protocol_engine import ProtocolEngine +from opentrons_shared_data.errors import ErrorCodes + from robot_server.errors import ErrorDetails from robot_server.hardware import get_hardware from robot_server.runs import EngineStore, EngineConflictError, get_engine_store @@ -24,6 +26,7 @@ class RunActive(ErrorDetails): "There is an active run. Close the current run" " to issue commands via POST /commands." ) + errorCode: str = ErrorCodes.ROBOT_IN_USE.value.code async def get_default_engine( @@ -35,7 +38,7 @@ async def get_default_engine( try: engine = await engine_store.get_default_engine() except EngineConflictError as e: - raise RunActive().as_error(status.HTTP_409_CONFLICT) from e + raise RunActive.from_exc(e).as_error(status.HTTP_409_CONFLICT) from e attached_modules = hardware_api.attached_modules attached_module_spec = { diff --git a/robot-server/robot_server/commands/router.py b/robot-server/robot_server/commands/router.py index a09840c280c..0d025b71dea 100644 --- a/robot-server/robot_server/commands/router.py +++ b/robot-server/robot_server/commands/router.py @@ -7,6 +7,7 @@ from opentrons.protocol_engine import ProtocolEngine, CommandIntent from opentrons.protocol_engine.errors import CommandDoesNotExistError +from opentrons_shared_data.errors import ErrorCodes from robot_server.errors import ErrorDetails, ErrorBody from robot_server.service.json_api import ( @@ -31,6 +32,7 @@ class CommandNotFound(ErrorDetails): id: Literal["StatelessCommandNotFound"] = "StatelessCommandNotFound" title: str = "Stateless Command Not Found" + errorCode: str = ErrorCodes.GENERAL_ERROR.value.code @commands_router.post( @@ -177,7 +179,7 @@ async def get_command( command = engine.state_view.commands.get(commandId) except CommandDoesNotExistError as e: - raise CommandNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) from e + raise CommandNotFound.from_exc(e).as_error(status.HTTP_404_NOT_FOUND) from e return await PydanticResponse.create( content=SimpleBody.construct(data=cast(StatelessCommand, command)), diff --git a/robot-server/robot_server/errors/error_responses.py b/robot-server/robot_server/errors/error_responses.py index 0ec3f177893..b05431560e1 100644 --- a/robot-server/robot_server/errors/error_responses.py +++ b/robot-server/robot_server/errors/error_responses.py @@ -1,13 +1,13 @@ """JSON API errors and response models.""" from pydantic import BaseModel, Field from pydantic.generics import GenericModel -from typing import Any, Dict, Generic, Optional, Sequence, Tuple, TypeVar +from typing import Any, Dict, Generic, Optional, Sequence, TypeVar, Type from robot_server.service.json_api import BaseResponseBody, ResourceLinks -from opentrons_shared_data.errors import ErrorCodes, EnumeratedError +from opentrons_shared_data.errors import EnumeratedError, PythonException -class ApiError(EnumeratedError): +class ApiError(Exception): """An exception to throw when an endpoint should respond with an error.""" def __init__(self, status_code: int, content: Dict[str, Any]) -> None: @@ -107,10 +107,48 @@ def get_some_model(): ), ) errorCode: str = Field( - ..., + None, description=("The Opentrons error code associated with the error"), ) + @classmethod + def from_exc( + cls: Type["ErrorDetailsT"], + exc: BaseException, + *, + override_defaults: bool = False, + **supplemental_kwargs: Any + ) -> "ErrorDetailsT": + """Build an ErrorDetails model from an exception. + + To allow for custom child models of the ErrorDetails base setting separate + defaults, if a default is set for a given field it won't be set from the + exception unless override_defaults is True. + """ + values = {k: v for k, v in supplemental_kwargs.items()} + if not isinstance(exc, EnumeratedError): + checked_exc: EnumeratedError = PythonException(exc) + else: + checked_exc = exc + values["detail"] = checked_exc.message.strip() + values["errorCode"] = checked_exc.code.value.code + + def _exc_to_meta(exc_val: EnumeratedError) -> Dict[str, Any]: + return { + "type": exc_val.detail.get("class", exc_val.__class__.__name__), + "code": exc_val.code.value.code, + "message": exc_val.message.strip(), + "detail": {k: v for k, v in exc_val.detail.items()}, + "wrapping": [_exc_to_meta(wrapped) for wrapped in exc_val.wrapping], + } + + values["meta"] = _exc_to_meta(checked_exc) + if not override_defaults: + for fieldname, fieldval in cls.__fields__.items(): + if not fieldval.required and fieldval.default is not None: + values.pop(fieldname, None) + return cls(**values) + def as_error(self, status_code: int) -> ApiError: """Serial this ErrorDetails as an ApiError from an ErrorResponse.""" return ErrorBody(errors=(self,)).as_error(status_code) @@ -130,11 +168,25 @@ class LegacyErrorResponse(BaseErrorBody): ..., description="The Opentrons error code associated with the error" ) + @classmethod + def from_exc( + cls: Type["LegacyErrorResponse"], exc: BaseException + ) -> "LegacyErrorResponse": + """Build a response from an exception, preserving some detail.""" + if not isinstance(exc, EnumeratedError): + checked_exc: EnumeratedError = PythonException(exc) + else: + checked_exc = exc + + return cls( + message=checked_exc.message.strip(), errorCode=checked_exc.code.value.code + ) + class ErrorBody(BaseErrorBody, GenericModel, Generic[ErrorDetailsT]): """A response body for a single error.""" - errors: Tuple[ErrorDetailsT] = Field(..., description="Error details.") + errors: Sequence[ErrorDetailsT] = Field(..., description="Error details.") links: Optional[ResourceLinks] = Field( None, description=( diff --git a/robot-server/robot_server/errors/exception_handlers.py b/robot-server/robot_server/errors/exception_handlers.py index b1ae071c3d3..2e21f1cb8d6 100644 --- a/robot-server/robot_server/errors/exception_handlers.py +++ b/robot-server/robot_server/errors/exception_handlers.py @@ -5,9 +5,10 @@ from fastapi.responses import JSONResponse from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException -from traceback import format_exception, format_exception_only from typing import Any, Callable, Coroutine, Dict, Optional, Sequence, Type, Union +from opentrons_shared_data.errors import ErrorCodes, EnumeratedError, PythonException + from robot_server.versioning import ( API_VERSION, MIN_API_VERSION, @@ -40,7 +41,12 @@ def _code_or_default(exc: BaseException) -> str: if isinstance(exc, EnumeratedError): - return exc.error_code + # For a reason I cannot fathom, mypy thinks this is an Any. + # reveal_type(exc) # -> opentrons_shared_data.errors.exceptions.EnumeratedError + # reveal_type(exc.code) # -> opentrons_shared_data.errors.codes.ErrorCodes + # reveal_type(exc.code.value) # Any (????????????) + # This doesn't happen anywhere else or indeed in the else side of this clause. + return exc.code.value.code # type: ignore [no-any-return] else: return ErrorCodes.GENERAL_ERROR.value.code @@ -102,7 +108,9 @@ async def handle_framework_error( ) -> JSONResponse: """Map an HTTP exception from the framework to an API response.""" if _route_is_legacy(request): - response: BaseErrorBody = LegacyErrorResponse(message=error.detail) + response: BaseErrorBody = LegacyErrorResponse( + message=error.detail, errorCode=ErrorCodes.GENERAL_ERROR.value.code + ) else: response = BadRequest(detail=error.detail) @@ -121,7 +129,9 @@ async def handle_validation_error( f"{'.'.join([str(v) for v in val_error['loc']])}: {val_error['msg']}" for val_error in validation_errors ) - response: BaseErrorBody = LegacyErrorResponse(message=message) + response: BaseErrorBody = LegacyErrorResponse( + message=message, errorCode=ErrorCodes.GENERAL_ERROR.value.code + ) else: response = MultiErrorResponse( errors=[ @@ -139,19 +149,19 @@ async def handle_validation_error( ) -async def handle_unexpected_error(request: Request, error: Exception) -> JSONResponse: +async def handle_unexpected_error( + request: Request, error: BaseException +) -> JSONResponse: """Map an unhandled Exception to an API response.""" - detail = "".join(format_exception_only(type(error), error)).strip() - stacktrace = "".join( - format_exception(type(error), error, error.__traceback__, limit=-5) - ).strip() + if isinstance(error, EnumeratedError): + enumerated: EnumeratedError = error + else: + enumerated = PythonException(error) if _route_is_legacy(request): - response: BaseErrorBody = LegacyErrorResponse( - message=detail, errorCode=_code_or_default(error) - ) + response: BaseErrorBody = LegacyErrorResponse.from_exc(enumerated) else: - response = UnexpectedError(detail=detail, meta={"stacktrace": stacktrace}) + response = UnexpectedError.from_exc(enumerated) return await handle_api_error( request, @@ -163,15 +173,10 @@ async def handle_firmware_upgrade_required_error( request: Request, error: HWFirmwareUpdateRequired ) -> JSONResponse: """Map a FirmwareUpdateRequired error from hardware to an API response.""" - detail = "".join( - format_exception(type(error), error, error.__traceback__, limit=0) - ).strip() if _route_is_legacy(request): - response: BaseErrorBody = LegacyErrorResponse(message=detail) + response: BaseErrorBody = LegacyErrorResponse.from_exc(error) else: - response = FirmwareUpdateRequired( - detail=detail, meta={"status_url": "/subsystems/status"} - ) + response = FirmwareUpdateRequired.from_exc(error) return await handle_api_error( request, response.as_error(status.HTTP_503_SERVICE_UNAVAILABLE) ) diff --git a/robot-server/robot_server/errors/global_errors.py b/robot-server/robot_server/errors/global_errors.py index 07b47fa2d77..0236cac207e 100644 --- a/robot-server/robot_server/errors/global_errors.py +++ b/robot-server/robot_server/errors/global_errors.py @@ -1,5 +1,6 @@ """Global error types.""" from typing_extensions import Literal +from typing import Type, Any from .error_responses import ErrorDetails from opentrons_shared_data.errors import ErrorCodes @@ -43,3 +44,22 @@ class FirmwareUpdateRequired(ErrorDetails): id: Literal["FirmwareUpdateRequired"] = "FirmwareUpdateRequired" title: str = "Firmware Update Required" errorCode: str = ErrorCodes.FIRMWARE_UPDATE_REQUIRED.value.code + + @classmethod + def from_exc( + cls: Type["FirmwareUpdateRequired"], + exc: BaseException, + *, + override_defaults: bool = False, + **supplemental_kwargs: Any + ) -> "FirmwareUpdateRequired": + """Build a FirmwareUpdateRequired from a specific exception. Preserves metadata.""" + parent_inst = ErrorDetails.from_exc( + exc, override_defaults=override_defaults, **supplemental_kwargs + ) + inst = FirmwareUpdateRequired(**parent_inst.dict()) + if not inst.meta: + inst.meta = {"update_url": "/subsystems/update"} + else: + inst.meta["update_url"] = "/subsystems/update" + return inst diff --git a/robot-server/robot_server/robot/calibration/errors.py b/robot-server/robot_server/robot/calibration/errors.py index 3ed540a5497..80113b3ae07 100644 --- a/robot-server/robot_server/robot/calibration/errors.py +++ b/robot-server/robot_server/robot/calibration/errors.py @@ -1,5 +1,6 @@ from http import HTTPStatus +from opentrons_shared_data.errors import ErrorCodes from robot_server.service.errors import ErrorDef, ErrorCreateDef @@ -8,40 +9,48 @@ class CalibrationError(ErrorDef): status_code=HTTPStatus.FORBIDDEN, title="No Pipette Attached", format_string="No pipette present on {mount} mount", + error_code=ErrorCodes.PIPETTE_NOT_PRESENT.value.code, ) NO_PIPETTE_ATTACHED = ErrorCreateDef( status_code=HTTPStatus.FORBIDDEN, title="No Pipette Attached", format_string="Cannot start {flow} with fewer than one pipette", + error_code=ErrorCodes.PIPETTE_NOT_PRESENT.value.code, ) BAD_LABWARE_DEF = ErrorCreateDef( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, title="Bad Labware Definition", format_string="Bad definition for tip rack under calibration", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) BAD_STATE_TRANSITION = ErrorCreateDef( status_code=HTTPStatus.CONFLICT, title="Illegal State Transition", format_string="The action {action} may not occur in the state {state}", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) NO_STATE_TRANSITION = ErrorCreateDef( status_code=HTTPStatus.CONFLICT, title="No State Transition", format_string="No transition available for state {state}", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) UNMET_STATE_TRANSITION_REQ = ErrorCreateDef( status_code=HTTPStatus.CONFLICT, title="Unmet State Transition Requirement", format_string="The command handler {handler} may not occur in the" ' state {state} when "{condition}" is not true', + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) UNCALIBRATED_ROBOT = ErrorCreateDef( status_code=HTTPStatus.CONFLICT, title="No Calibration Data Found", format_string="Cannot start {flow} without robot calibration", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) ERROR_DURING_TRANSITION = ErrorCreateDef( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, title="Error During State Transition", format_string="Event {action} failed to transition " "from {state}: {error}", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) diff --git a/robot-server/robot_server/runs/router/actions_router.py b/robot-server/robot_server/runs/router/actions_router.py index 347e86156ff..c87c9f59fe6 100644 --- a/robot-server/robot_server/runs/router/actions_router.py +++ b/robot-server/robot_server/runs/router/actions_router.py @@ -116,12 +116,10 @@ async def create_run_action( ) except RunActionNotAllowedError as e: - raise RunActionNotAllowed(detail=str(e)).as_error( - status.HTTP_409_CONFLICT - ) from e + raise RunActionNotAllowed.from_exc(e).as_error(status.HTTP_409_CONFLICT) from e except RunNotFoundError as e: - raise RunNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) from e + raise RunNotFound.from_exc(e).as_error(status.HTTP_404_NOT_FOUND) from e return await PydanticResponse.create( content=SimpleBody.construct(data=action), diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 4c2f1ae5ad0..67a4db8d4a6 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -11,6 +11,8 @@ from fastapi import APIRouter, Depends, status, Query from pydantic import BaseModel, Field +from opentrons_shared_data.errors import ErrorCodes + from robot_server.errors import ErrorDetails, ErrorBody from robot_server.service.dependencies import get_current_time, get_unique_id @@ -48,6 +50,7 @@ class RunNotFound(ErrorDetails): id: Literal["RunNotFound"] = "RunNotFound" title: str = "Run Not Found" + errorCode: str = ErrorCodes.GENERAL_ERROR.value.code class RunAlreadyActive(ErrorDetails): @@ -55,6 +58,7 @@ class RunAlreadyActive(ErrorDetails): id: Literal["RunAlreadyActive"] = "RunAlreadyActive" title: str = "Run Already Active" + errorCode: str = ErrorCodes.ROBOT_IN_USE.value.code class RunNotIdle(ErrorDetails): @@ -66,6 +70,7 @@ class RunNotIdle(ErrorDetails): "Run is currently active. Allow the run to finish or" " stop it with a `stop` action before attempting to modify it." ) + errorCode: str = ErrorCodes.ROBOT_IN_USE.value.code class RunStopped(ErrorDetails): @@ -73,6 +78,7 @@ class RunStopped(ErrorDetails): id: Literal["RunStopped"] = "RunStopped" title: str = "Run Stopped" + errorCode: str = ErrorCodes.GENERAL_ERROR.value.code class AllRunsLinks(BaseModel): diff --git a/robot-server/robot_server/runs/router/commands_router.py b/robot-server/robot_server/runs/router/commands_router.py index 063bd02760e..e765ed1ec49 100644 --- a/robot-server/robot_server/runs/router/commands_router.py +++ b/robot-server/robot_server/runs/router/commands_router.py @@ -197,9 +197,9 @@ async def create_run_command( command = protocol_engine.add_command(command_create) except pe_errors.SetupCommandNotAllowedError as e: - raise CommandNotAllowed(detail=str(e)).as_error(status.HTTP_409_CONFLICT) + raise CommandNotAllowed.from_exc(e).as_error(status.HTTP_409_CONFLICT) except pe_errors.RunStoppedError as e: - raise RunStopped(detail=str(e)).as_error(status.HTTP_409_CONFLICT) + raise RunStopped.from_exc(e).as_error(status.HTTP_409_CONFLICT) if waitUntilComplete: timeout_sec = None if timeout is None else timeout / 1000.0 @@ -261,7 +261,7 @@ async def get_run_commands( length=pageLength, ) except RunNotFoundError as e: - raise RunNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) from e + raise RunNotFound.from_exc(e).as_error(status.HTTP_404_NOT_FOUND) from e current_command = run_data_manager.get_current_command(run_id=runId) @@ -335,9 +335,9 @@ async def get_run_command( try: command = run_data_manager.get_command(run_id=runId, command_id=commandId) except RunNotFoundError as e: - raise RunNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) from e + raise RunNotFound.from_exc(e).as_error(status.HTTP_404_NOT_FOUND) from e except CommandNotFoundError as e: - raise CommandNotFound(detail=str(e)).as_error(status.HTTP_404_NOT_FOUND) from e + raise CommandNotFound.from_exc(e).as_error(status.HTTP_404_NOT_FOUND) from e return await PydanticResponse.create( content=SimpleBody.construct(data=command), diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 312985c0cca..6a75c1a3131 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -3,6 +3,7 @@ from datetime import datetime from opentrons.protocol_engine import ProtocolEngineError +from opentrons_shared_data.errors.exceptions import RoboticsInteractionError from robot_server.service.task_runner import TaskRunner @@ -14,7 +15,7 @@ log = logging.getLogger(__name__) -class RunActionNotAllowedError(ValueError): +class RunActionNotAllowedError(RoboticsInteractionError): """Error raised when a given run action is not allowed.""" @@ -79,7 +80,7 @@ def create_action( self._task_runner.run(self._engine_store.runner.stop) except ProtocolEngineError as e: - raise RunActionNotAllowedError(str(e)) from e + raise RunActionNotAllowedError(message=e.message, wrapping=[e]) from e self._run_store.insert_action(run_id=self._run_id, action=action) diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 85b4eee4647..485392cc9bc 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -17,6 +17,7 @@ LabwareOffsetCreate, Liquid, ) +from opentrons_shared_data.errors import GeneralError from robot_server.service.json_api import ResourceModel from .action_models import RunAction @@ -149,9 +150,11 @@ class LabwareDefinitionSummary(BaseModel): ) -class RunNotFoundError(ValueError): +class RunNotFoundError(GeneralError): """Error raised when a given Run ID is not found in the store.""" def __init__(self, run_id: str) -> None: """Initialize the error message from the missing ID.""" - super().__init__(f"Run {run_id} was not found.") + super().__init__( + message=f"Run {run_id} was not found.", detail={"runId": run_id} + ) diff --git a/robot-server/robot_server/service/errors.py b/robot-server/robot_server/service/errors.py index ec2c5821853..dbd0bcfdd55 100644 --- a/robot-server/robot_server/service/errors.py +++ b/robot-server/robot_server/service/errors.py @@ -2,9 +2,11 @@ # robot_server/errors/error_responses.py and robot_server/errors/global_errors.py from dataclasses import dataclass, asdict from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Sequence, Tuple from starlette import status as status_codes +from opentrons_shared_data.errors import ErrorCodes + from robot_server.errors import ApiError, ErrorSource, ErrorDetails, ErrorBody from robot_server.service.json_api import ResourceLinks @@ -14,6 +16,7 @@ class ErrorCreateDef: status_code: int title: str format_string: str + error_code: str class ErrorDef(ErrorCreateDef, Enum): @@ -38,6 +41,7 @@ def __init__( links: Optional[ResourceLinks] = None, source: Optional[ErrorSource] = None, meta: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[BaseException]] = None, *fmt_args, **fmt_kw_args ): @@ -51,6 +55,11 @@ def __init__( :param fmt_args: format_string args :param fmt_kw_args: format_string kw_args """ + checked_wrapping = wrapping or [] + wrapped_details: Tuple[ErrorDetails, ...] = tuple( + ErrorDetails.from_exc(exc) for exc in checked_wrapping + ) + content = ErrorBody( errors=( ErrorDetails( @@ -59,7 +68,9 @@ def __init__( detail=definition.format_string.format(*fmt_args, **fmt_kw_args), source=source, meta=meta, + errorCode=definition.error_code, ), + *wrapped_details, ), links=links, ).dict(exclude_none=True) @@ -77,24 +88,29 @@ class CommonErrorDef(ErrorDef): status_code=status_codes.HTTP_500_INTERNAL_SERVER_ERROR, title="Internal Server Error", format_string="{error}", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) NOT_IMPLEMENTED = ErrorCreateDef( status_code=status_codes.HTTP_501_NOT_IMPLEMENTED, title="Not implemented", format_string="Method not implemented. {error}", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) RESOURCE_NOT_FOUND = ErrorCreateDef( status_code=status_codes.HTTP_404_NOT_FOUND, title="Resource Not Found", format_string="Resource type '{resource}' with id '{id}' was not found", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) ACTION_FORBIDDEN = ErrorCreateDef( status_code=status_codes.HTTP_403_FORBIDDEN, title="Action Forbidden", format_string="{reason}", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) RESOURCE_ALREADY_EXISTS = ErrorCreateDef( status_code=status_codes.HTTP_403_FORBIDDEN, title="Resource Exists", format_string="A '{resource}' with id '{id}' already exists", + error_code=ErrorCodes.GENERAL_ERROR.value.code, ) diff --git a/robot-server/robot_server/service/labware/router.py b/robot-server/robot_server/service/labware/router.py index e3d1fdfbf59..e008af014d1 100644 --- a/robot-server/robot_server/service/labware/router.py +++ b/robot-server/robot_server/service/labware/router.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, status +from opentrons_shared_data.errors import ErrorCodes from robot_server.errors import ErrorDetails, ErrorBody from robot_server.versioning import get_requested_version from robot_server.service.labware import models as lw_models @@ -25,6 +26,7 @@ class LabwareCalibrationEndpointsRemoved(ErrorDetails): ] = "LabwareCalibrationEndpointsRemoved" title: str = "Labware Calibration Endpoints Removed" detail: str = "Use the `/runs` endpoints to manage labware offsets." + errorCode: str = ErrorCodes.API_REMOVED.value.code @router.get( diff --git a/robot-server/robot_server/service/legacy/routers/control.py b/robot-server/robot_server/service/legacy/routers/control.py index 649f88b14ba..8258032bb98 100644 --- a/robot-server/robot_server/service/legacy/routers/control.py +++ b/robot-server/robot_server/service/legacy/routers/control.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Query, Depends from starlette import status +from opentrons_shared_data.errors import ErrorCodes from opentrons.hardware_control import ( ThreadedAsyncLock, ThreadedAsyncForbidden, @@ -79,7 +80,7 @@ async def post_move_robot( pos = await _do_move(hardware=hardware, robot_move_target=robot_move_target) return V1BasicResponse(message=f"Move complete. New position: {pos}") except ThreadedAsyncForbidden as e: - raise LegacyErrorResponse(message=str(e)).as_error(status.HTTP_403_FORBIDDEN) + raise LegacyErrorResponse.from_exc(e).as_error(status.HTTP_403_FORBIDDEN) @router.post( @@ -113,13 +114,14 @@ async def post_home_robot( await home() message = "Homing robot." else: - raise LegacyErrorResponse(message=f"{target} is invalid").as_error( - status.HTTP_400_BAD_REQUEST - ) + raise LegacyErrorResponse( + message=f"{target} is invalid", + errorCode=ErrorCodes.INVALID_ACTUATOR.value.code, + ).as_error(status.HTTP_400_BAD_REQUEST) return V1BasicResponse(message=message) except ThreadedAsyncForbidden as e: - raise LegacyErrorResponse(message=str(e)).as_error(status.HTTP_403_FORBIDDEN) + raise LegacyErrorResponse.from_exc(e).as_error(status.HTTP_403_FORBIDDEN) @router.get( diff --git a/robot-server/robot_server/service/legacy/routers/modules.py b/robot-server/robot_server/service/legacy/routers/modules.py index f28ec1bce7d..b6c4c30474f 100644 --- a/robot-server/robot_server/service/legacy/routers/modules.py +++ b/robot-server/robot_server/service/legacy/routers/modules.py @@ -6,6 +6,9 @@ from opentrons.hardware_control import modules, HardwareControlAPI from opentrons.hardware_control.modules import AbstractModule +from opentrons_shared_data.errors.exceptions import APIRemoved, ModuleNotPresent +from opentrons_shared_data.errors.codes import ErrorCodes + from robot_server.errors import LegacyErrorResponse from robot_server.hardware import get_hardware from robot_server.versioning import get_requested_version @@ -77,23 +80,27 @@ async def post_serial_command( ) -> SerialCommandResponse: """Send a command on device identified by serial""" if requested_version >= 3: - raise LegacyErrorResponse( - message=("This endpoint has been removed. Use POST /commands instead.") + raise LegacyErrorResponse.from_exc( + APIRemoved( + "/modules/{serial}", + "3", + "This endpoint has been removed. Use POST /commands instead.", + ), ).as_error(status.HTTP_410_GONE) attached_modules = hardware.attached_modules if not attached_modules: - raise LegacyErrorResponse(message="No connected modules").as_error( - status.HTTP_404_NOT_FOUND - ) + raise LegacyErrorResponse.from_exc( + ModuleNotPresent(serial, message="No connected modules") + ).as_error(status.HTTP_404_NOT_FOUND) # Search for the module matching_mod = find_matching_module(serial, attached_modules) if not matching_mod: - raise LegacyErrorResponse(message="Specified module not found").as_error( - status.HTTP_404_NOT_FOUND - ) + raise LegacyErrorResponse.from_exc( + ModuleNotPresent(serial, message="Specified module not found") + ).as_error(status.HTTP_404_NOT_FOUND) if hasattr(matching_mod, command.command_type): clean_args = command.args or [] @@ -107,13 +114,15 @@ async def post_serial_command( raise LegacyErrorResponse( message=f"Server encountered a TypeError " f"while running {method} : {e}. " - f"Possibly a type mismatch in args" + f"Possibly a type mismatch in args", + errorCode=ErrorCodes.ROBOTICS_INTERACTION_ERROR.value.code, ).as_error(status.HTTP_400_BAD_REQUEST) else: return SerialCommandResponse(message="Success", returnValue=val) else: raise LegacyErrorResponse( - message=f"Module does not have command: {command.command_type}" + message=f"Module does not have command: {command.command_type}", + errorCode=ErrorCodes.ROBOTICS_INTERACTION_ERROR.value.code, ).as_error(status.HTTP_400_BAD_REQUEST) @@ -139,9 +148,9 @@ async def post_serial_update( matching_module = find_matching_module(serial, attached_modules) if not matching_module: - raise LegacyErrorResponse(message=f"Module {serial} not found").as_error( - status.HTTP_404_NOT_FOUND - ) + raise LegacyErrorResponse.from_exc( + ModuleNotPresent(serial, message=f"Module {serial} not found") + ).as_error(status.HTTP_404_NOT_FOUND) try: if matching_module.bundled_fw: @@ -166,7 +175,9 @@ async def post_serial_update( except asyncio.TimeoutError: res = "Module not responding" status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - raise LegacyErrorResponse(message=res).as_error(status_code) + raise LegacyErrorResponse( + message=res, errorCode=ErrorCodes.FIRMWARE_UPDATE_FAILED.value.code + ).as_error(status_code) def find_matching_module( diff --git a/robot-server/robot_server/service/legacy/routers/motors.py b/robot-server/robot_server/service/legacy/routers/motors.py index 7ba6f160358..564797fe2a7 100644 --- a/robot-server/robot_server/service/legacy/routers/motors.py +++ b/robot-server/robot_server/service/legacy/routers/motors.py @@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends from pydantic import ValidationError +from opentrons_shared_data.errors import ErrorCodes + from opentrons.hardware_control.types import Axis from opentrons.hardware_control import HardwareControlAPI from opentrons.protocol_engine.errors import HardwareNotSupportedError @@ -34,8 +36,10 @@ async def get_engaged_motors( } return model.EngagedMotors(**axes_dict) except ValidationError as e: - raise LegacyErrorResponse(message=str(e)).as_error( - status.HTTP_500_INTERNAL_SERVER_ERROR + raise LegacyErrorResponse( + message=str(e), errorCode=ErrorCodes.GENERAL_ERROR.value.code + ).as_error( + status.HTTP_500_INTERNAL_SERVER_ERROR, ) diff --git a/robot-server/robot_server/service/legacy/routers/networking.py b/robot-server/robot_server/service/legacy/routers/networking.py index e6821e9f270..8a1786c3686 100644 --- a/robot-server/robot_server/service/legacy/routers/networking.py +++ b/robot-server/robot_server/service/legacy/routers/networking.py @@ -5,6 +5,7 @@ from starlette import status from starlette.responses import JSONResponse from fastapi import APIRouter, HTTPException, File, Path, UploadFile +from opentrons_shared_data.errors import ErrorCodes from opentrons.system import nmcli, wifi from robot_server.errors import LegacyErrorResponse @@ -98,12 +99,12 @@ async def post_wifi_configure( except (ValueError, TypeError) as e: # Indicates an unexpected kwarg; check is done here to avoid keeping # the _check_configure_args signature up to date with nmcli.configure - raise LegacyErrorResponse(message=str(e)).as_error(status.HTTP_400_BAD_REQUEST) + raise LegacyErrorResponse.from_exc(e).as_error(status.HTTP_400_BAD_REQUEST) if not ok: - raise LegacyErrorResponse(message=message).as_error( - status.HTTP_401_UNAUTHORIZED - ) + raise LegacyErrorResponse( + message=message, errorCode=ErrorCodes.GENERAL_ERROR.value.code + ).as_error(status.HTTP_401_UNAUTHORIZED) return WifiConfigurationResponse(message=message, ssid=configuration.ssid) @@ -173,9 +174,10 @@ async def delete_wifi_key( """Delete wifi key handler""" deleted_file = wifi.remove_key(key_uuid) if not deleted_file: - raise LegacyErrorResponse(message=f"No such key file {key_uuid}").as_error( - status.HTTP_404_NOT_FOUND - ) + raise LegacyErrorResponse( + message=f"No such key file {key_uuid}", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ).as_error(status.HTTP_404_NOT_FOUND) return V1BasicResponse(message=f"Key file {deleted_file} deleted") diff --git a/robot-server/robot_server/service/legacy/routers/settings.py b/robot-server/robot_server/service/legacy/routers/settings.py index 7b072e0054b..5e0225aaaac 100644 --- a/robot-server/robot_server/service/legacy/routers/settings.py +++ b/robot-server/robot_server/service/legacy/routers/settings.py @@ -5,6 +5,7 @@ from starlette import status from fastapi import APIRouter, Depends +from opentrons_shared_data.errors import ErrorCodes from opentrons.hardware_control import HardwareControlAPI from opentrons.system import log_control from opentrons.config import ( @@ -59,10 +60,10 @@ async def post_settings( await advanced_settings.set_adv_setting(update.id, update.value) await hardware.set_status_bar_enabled(ff.status_bar_enabled()) except ValueError as e: - raise LegacyErrorResponse(message=str(e)).as_error(status.HTTP_400_BAD_REQUEST) + raise LegacyErrorResponse.from_exc(e).as_error(status.HTTP_400_BAD_REQUEST) except advanced_settings.SettingException as e: # Severe internal error - raise LegacyErrorResponse(message=str(e)).as_error( + raise LegacyErrorResponse.from_exc(e).as_error( status.HTTP_500_INTERNAL_SERVER_ERROR ) return _create_settings_response() @@ -119,9 +120,10 @@ async def post_log_level_local( """Update local log level""" level = log_level.log_level if not level: - raise LegacyErrorResponse(message="log_level must be set").as_error( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY - ) + raise LegacyErrorResponse( + message="log_level must be set", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ).as_error(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) # Level name is upper case level_name = level.value.upper() # Set the log levels @@ -165,8 +167,10 @@ async def post_log_level_upstream(log_level: LogLevel) -> V1BasicResponse: if code != 0: msg = f"Could not reload config: {stdout} {stderr}" log.error(msg) - raise LegacyErrorResponse(message=msg).as_error( - status.HTTP_500_INTERNAL_SERVER_ERROR + raise LegacyErrorResponse( + message=msg, errorCode=ErrorCodes.GENERAL_ERROR.value.code + ).as_error( + status.HTTP_500_INTERNAL_SERVER_ERROR, ) if log_level_name: @@ -217,7 +221,8 @@ async def post_settings_reset_options( if not_allowed_options: not_allowed_array_to_str = " ".join(not_allowed_options) raise LegacyErrorResponse( - message=f"{not_allowed_array_to_str} is not a valid reset option." + message=f"{not_allowed_array_to_str} is not a valid reset option.", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, ).as_error(status.HTTP_403_FORBIDDEN) options = set(k for k, v in factory_reset_commands.items() if v) @@ -278,7 +283,8 @@ async def get_pipette_settings() -> MultiPipetteSettings: async def get_pipette_setting(pipette_id: str) -> PipetteSettings: if pipette_id not in pipette_config.known_pipettes(): raise LegacyErrorResponse( - message=f"{pipette_id} is not a valid pipette id" + message=f"{pipette_id} is not a valid pipette id", + errorCode=ErrorCodes.PIPETTE_NOT_PRESENT.value.code, ).as_error(status.HTTP_404_NOT_FOUND) r = _pipette_settings_from_config(pipette_config, pipette_id) return r @@ -305,9 +311,9 @@ async def patch_pipette_setting( try: pipette_config.override(fields=field_values, pipette_id=pipette_id) except ValueError as e: - raise LegacyErrorResponse(message=str(e)).as_error( - status.HTTP_412_PRECONDITION_FAILED - ) + raise LegacyErrorResponse( + message=str(e), errorCode=ErrorCodes.GENERAL_ERROR.value.code + ).as_error(status.HTTP_412_PRECONDITION_FAILED) r = _pipette_settings_from_config(pipette_config, pipette_id) return r diff --git a/robot-server/tests/errors/test_error_responses.py b/robot-server/tests/errors/test_error_responses.py index 173da34ce74..0f181dbc36c 100644 --- a/robot-server/tests/errors/test_error_responses.py +++ b/robot-server/tests/errors/test_error_responses.py @@ -1,4 +1,5 @@ """Tests for API error exceptions and response model serialization.""" +from opentrons_shared_data.errors import ErrorCodes from robot_server.errors.error_responses import ( ApiError, ErrorSource, @@ -58,12 +59,12 @@ def test_error_details_with_meta() -> None: def test_legacy_error_response() -> None: """It should serialize an error response from a LegacyErrorResponse.""" result = LegacyErrorResponse( - message="Some error detail", + message="Some error detail", errorCode=ErrorCodes.GENERAL_ERROR.value.code ).as_error(status_code=400) assert isinstance(result, ApiError) assert result.status_code == 400 - assert result.content == {"message": "Some error detail"} + assert result.content == {"message": "Some error detail", "errorCode": "4000"} def test_error_response() -> None: diff --git a/robot-server/tests/errors/test_exception_handlers.py b/robot-server/tests/errors/test_exception_handlers.py index 81630b9d410..2a6687cc385 100644 --- a/robot-server/tests/errors/test_exception_handlers.py +++ b/robot-server/tests/errors/test_exception_handlers.py @@ -69,10 +69,19 @@ def trigger_unhandled_exception() -> None: "id": "UnexpectedError", "title": "Unexpected Internal Error", "detail": "Exception: Oh no!", + "errorCode": "4000", "meta": { - "stacktrace": matchers.StringMatching( - r'raise Exception\("Oh no!"\)' - ) + "code": "4000", + "message": "Exception: Oh no!", + "type": "Exception", + "detail": { + "args": "('Oh no!',)", + "class": "Exception", + "traceback": matchers.StringMatching( + r'raise Exception\("Oh no!"\)' + ), + }, + "wrapping": [], }, } ] @@ -89,7 +98,7 @@ def trigger_unhandled_exception_legacy() -> None: response = client.get("/internal-server-error-legacy") assert response.status_code == 500 - assert response.json() == {"message": "Exception: Oh no!"} + assert response.json() == {"message": "Exception: Oh no!", "errorCode": "4000"} def test_handles_framework_exceptions(app: FastAPI, client: TestClient) -> None: @@ -105,6 +114,7 @@ def raise_method_not_allowed() -> None: assert response.json() == { "errors": [ { + "errorCode": "4000", "id": "BadRequest", "title": "Bad Request", "detail": "Method Not Allowed", @@ -124,6 +134,7 @@ def legacy_raise_method_not_allowed() -> None: assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED assert response.json() == { + "errorCode": "4000", "message": "Method Not Allowed", } @@ -144,18 +155,21 @@ def create_item(item: Item) -> Item: assert response.json() == { "errors": [ { + "errorCode": "4000", "id": "InvalidRequest", "title": "Invalid Request", "detail": "field required", "source": {"pointer": "/string_field"}, }, { + "errorCode": "4000", "id": "InvalidRequest", "title": "Invalid Request", "detail": "value is not a valid integer", "source": {"pointer": "/int_field"}, }, { + "errorCode": "4000", "id": "InvalidRequest", "title": "Invalid Request", "detail": "value could not be parsed to a boolean", @@ -178,6 +192,7 @@ def get_item(count: int) -> Item: assert response.json() == { "errors": [ { + "errorCode": "4000", "id": "InvalidRequest", "title": "Invalid Request", "detail": "value is not a valid integer", @@ -200,6 +215,7 @@ def get_item(header_name: str = Header(...)) -> Item: assert response.json() == { "errors": [ { + "errorCode": "4000", "id": "InvalidRequest", "title": "Invalid Request", "detail": "field required", @@ -223,9 +239,10 @@ def create_item_legacy(item: Item) -> Item: assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.json() == { + "errorCode": "4000", "message": ( "body.string_field: none is not an allowed value; " "body.int_field: value is not a valid integer; " "body.array_field.0: value could not be parsed to a boolean" - ) + ), } 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 8c17e5efdaa..f61dd88f331 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,6 @@ stages: errors: - id: 'RunActionNotAllowed' title: 'Run Action Not Allowed' - detail: 'Error 4000 GENERAL_ERROR (PauseNotAllowedError): Cannot pause a run that is not running.' + detail: 'Cannot pause a run that is not running.' + errorCode: '3000' + meta: !anydict diff --git a/robot-server/tests/integration/system/test_system_time.tavern.yaml b/robot-server/tests/integration/system/test_system_time.tavern.yaml index 309999d8714..04a5f03af05 100644 --- a/robot-server/tests/integration/system/test_system_time.tavern.yaml +++ b/robot-server/tests/integration/system/test_system_time.tavern.yaml @@ -39,6 +39,7 @@ stages: - id: "InvalidRequest" title: "Invalid Request" detail: "field required" + errorCode: '4000' source: pointer: "/data/systemTime" - name: System Time PUT request on a dev server raises error @@ -56,3 +57,4 @@ stages: - id: "UncategorizedError" title: "Not implemented" detail: "Method not implemented. Not supported on dev server." + errorCode: '4000' diff --git a/robot-server/tests/integration/test_identify.tavern.yaml b/robot-server/tests/integration/test_identify.tavern.yaml index 956187a386b..b753ef84db7 100644 --- a/robot-server/tests/integration/test_identify.tavern.yaml +++ b/robot-server/tests/integration/test_identify.tavern.yaml @@ -28,3 +28,4 @@ stages: status_code: 422 json: message: "query.seconds: field required" + errorCode: "4000" diff --git a/robot-server/tests/integration/test_labware_calibration_access.tavern.yaml b/robot-server/tests/integration/test_labware_calibration_access.tavern.yaml index d828a8772c3..eea44044af7 100644 --- a/robot-server/tests/integration/test_labware_calibration_access.tavern.yaml +++ b/robot-server/tests/integration/test_labware_calibration_access.tavern.yaml @@ -14,6 +14,7 @@ stages: - id: 'LabwareCalibrationEndpointsRemoved' title: 'Labware Calibration Endpoints Removed' detail: 'Use the `/runs` endpoints to manage labware offsets.' + errorCode: '4002' - name: GET /labware/calibrations/:id returns 410 request: url: '{ot2_server_base_url}/labware/calibrations/some-id' @@ -25,6 +26,7 @@ stages: - id: 'LabwareCalibrationEndpointsRemoved' title: 'Labware Calibration Endpoints Removed' detail: 'Use the `/runs` endpoints to manage labware offsets.' + errorCode: '4002' - name: DELETE /labware/calibrations/:id returns 410 request: url: '{ot2_server_base_url}/labware/calibrations/some-id' @@ -36,6 +38,7 @@ stages: - id: 'LabwareCalibrationEndpointsRemoved' title: 'Labware Calibration Endpoints Removed' detail: 'Use the `/runs` endpoints to manage labware offsets.' + errorCode: '4002' - name: GET /labware/calibrations returns empty list on version <= 3 request: url: '{ot2_server_base_url}/labware/calibrations' @@ -60,6 +63,7 @@ stages: - id: 'UncategorizedError' title: 'Resource Not Found' detail: "Resource type 'calibration' with id 'some-id' was not found" + errorCode: '4000' - name: DELETE /labware/calibrations/:id returns 404 on version <=3 request: url: '{ot2_server_base_url}/labware/calibrations/some-id' @@ -73,3 +77,4 @@ stages: - id: 'UncategorizedError' title: 'Resource Not Found' detail: "Resource type 'calibration' with id 'some-id' was not found" + errorCode: '4000' diff --git a/robot-server/tests/integration/test_settings.tavern.yaml b/robot-server/tests/integration/test_settings.tavern.yaml index 1c46ef934e5..4fec3317fb0 100644 --- a/robot-server/tests/integration/test_settings.tavern.yaml +++ b/robot-server/tests/integration/test_settings.tavern.yaml @@ -157,4 +157,5 @@ stages: response: status_code: 400 json: - message: '{tavern.request_vars.json.id} is not recognized' + message: 'ValueError: {tavern.request_vars.json.id} is not recognized' + errorCode: '4000' diff --git a/robot-server/tests/integration/test_settings_log_level.tavern.yaml b/robot-server/tests/integration/test_settings_log_level.tavern.yaml index ff580616858..0a7c4e6bcf4 100644 --- a/robot-server/tests/integration/test_settings_log_level.tavern.yaml +++ b/robot-server/tests/integration/test_settings_log_level.tavern.yaml @@ -38,6 +38,7 @@ stages: status_code: 422 json: message: "body.log_level: '{tavern.request_vars.json.log_level}' is not a valid LogLevels" + errorCode: '4000' --- test_name: POST Set log level to nothing marks: @@ -54,3 +55,4 @@ stages: status_code: 422 json: message: "log_level must be set" + errorCode: '4000' diff --git a/robot-server/tests/integration/test_settings_pipettes.tavern.yaml b/robot-server/tests/integration/test_settings_pipettes.tavern.yaml index 2a96aa7cf55..08b98b49d00 100644 --- a/robot-server/tests/integration/test_settings_pipettes.tavern.yaml +++ b/robot-server/tests/integration/test_settings_pipettes.tavern.yaml @@ -186,6 +186,7 @@ stages: status_code: 412 json: message: "dropTip out of range with {tavern.request_vars.json.fields.dropTip.value}" + errorCode: "4000" strict: true --- test_name: PATCH Pipette {pipette_id} value too high @@ -206,6 +207,7 @@ stages: status_code: 412 json: message: "dropTip out of range with {tavern.request_vars.json.fields.dropTip.value}" + errorCode: "4000" strict: true --- test_name: PATCH Pipette {pipette_id} no value diff --git a/robot-server/tests/integration/test_settings_reset_options.tavern.yaml b/robot-server/tests/integration/test_settings_reset_options.tavern.yaml index 61b80f6c74d..7dc49479bba 100644 --- a/robot-server/tests/integration/test_settings_reset_options.tavern.yaml +++ b/robot-server/tests/integration/test_settings_reset_options.tavern.yaml @@ -121,6 +121,7 @@ stages: status_code: 403 json: message: "gripperOffsetCalibrations is not a valid reset option." + errorCode: "4000" --- test_name: POST Reset non existant option marks: @@ -137,3 +138,4 @@ stages: status_code: 422 json: message: !re_search 'value is not a valid enumeration member' + errorCode: "4000" diff --git a/robot-server/tests/runs/router/test_actions_router.py b/robot-server/tests/runs/router/test_actions_router.py index 00763160b23..0c9dbfdb742 100644 --- a/robot-server/tests/runs/router/test_actions_router.py +++ b/robot-server/tests/runs/router/test_actions_router.py @@ -103,7 +103,7 @@ async def test_play_action_clears_maintenance_run( @pytest.mark.parametrize( ("exception", "expected_error_id", "expected_status_code"), [ - (RunActionNotAllowedError("oh no"), "RunActionNotAllowed", 409), + (RunActionNotAllowedError(message="oh no"), "RunActionNotAllowed", 409), (RunNotFoundError("oh no"), "RunNotFound", 404), ], ) diff --git a/robot-server/tests/runs/router/test_commands_router.py b/robot-server/tests/runs/router/test_commands_router.py index 21c3d63a56d..a53b7c49142 100644 --- a/robot-server/tests/runs/router/test_commands_router.py +++ b/robot-server/tests/runs/router/test_commands_router.py @@ -214,8 +214,9 @@ async def test_add_conflicting_setup_command( assert exc_info.value.status_code == 409 assert exc_info.value.content["errors"][0]["detail"] == matchers.StringMatching( - ".*4000.*oh no" + "oh no" ) + assert exc_info.value.content["errors"][0]["errorCode"] == "4000" async def test_add_command_to_stopped_engine( @@ -241,8 +242,9 @@ async def test_add_command_to_stopped_engine( assert exc_info.value.status_code == 409 assert exc_info.value.content["errors"][0]["detail"] == matchers.StringMatching( - ".*4000.*oh no" + "oh no" ) + assert exc_info.value.content["errors"][0]["errorCode"] == "4000" async def test_get_run_commands( diff --git a/robot-server/tests/service/legacy/routers/test_control.py b/robot-server/tests/service/legacy/routers/test_control.py index f45326a9587..411b3c060c0 100644 --- a/robot-server/tests/service/legacy/routers/test_control.py +++ b/robot-server/tests/service/legacy/routers/test_control.py @@ -244,7 +244,8 @@ async def failure(func): with pytest.raises(ApiError) as exc_info: await func assert exc_info.value.status_code == 403 - assert exc_info.value.content["message"].find("Robot is currently moving") == 0 + assert "currently moving" in exc_info.value.content["message"] + assert exc_info.value.content["errorCode"] == "4000" forbidden_home = loop.create_task( failure( diff --git a/robot-server/tests/service/legacy/routers/test_modules.py b/robot-server/tests/service/legacy/routers/test_modules.py index 5cbca9b2cb0..e98621e69ed 100644 --- a/robot-server/tests/service/legacy/routers/test_modules.py +++ b/robot-server/tests/service/legacy/routers/test_modules.py @@ -236,7 +236,10 @@ def test_post_serial_update_no_bundled_fw(api_client, hardware, magdeck): body = resp.json() assert resp.status_code == 500 - assert body == {"message": "Bundled fw file not found for module of type: magdeck"} + assert body == { + "message": "Bundled fw file not found for module of type: magdeck", + "errorCode": "1005", + } def test_post_serial_update_no_modules(api_client, hardware): @@ -244,7 +247,7 @@ def test_post_serial_update_no_modules(api_client, hardware): body = resp.json() assert resp.status_code == 404 - assert body == {"message": "Module dummySerialMD not found"} + assert body == {"message": "Module dummySerialMD not found", "errorCode": "3015"} def test_post_serial_update_no_match(api_client, hardware, tempdeck): @@ -254,7 +257,10 @@ def test_post_serial_update_no_match(api_client, hardware, tempdeck): body = resp.json() assert resp.status_code == 404 - assert body == {"message": "Module superDummySerialMD not found"} + assert body == { + "message": "Module superDummySerialMD not found", + "errorCode": "3015", + } def test_post_serial_update_error(api_client, hardware, magdeck): @@ -271,7 +277,7 @@ async def thrower(*args, **kwargs): body = resp.json() assert resp.status_code == 500 - assert body == {"message": "Update error: not possible"} + assert body == {"message": "Update error: not possible", "errorCode": "1005"} def test_post_serial_timeout_error(api_client, hardware, magdeck): @@ -288,7 +294,7 @@ async def thrower(*args, **kwargs): body = resp.json() assert resp.status_code == 500 - assert body == {"message": "Module not responding"} + assert body == {"message": "Module not responding", "errorCode": "1005"} def test_post_serial_update(api_client, hardware, tempdeck): diff --git a/robot-server/tests/service/legacy/routers/test_networking.py b/robot-server/tests/service/legacy/routers/test_networking.py index 0938c36850d..799c04c0479 100755 --- a/robot-server/tests/service/legacy/routers/test_networking.py +++ b/robot-server/tests/service/legacy/routers/test_networking.py @@ -135,7 +135,7 @@ async def mock_configure( resp = api_client.post("/wifi/configure", json={"ssid": "asasd", "foo": "bar"}) assert resp.status_code == 400 body = resp.json() - assert {"message": "nope!"} == body + assert {"message": "ValueError: nope!", "errorCode": "4000"} == body def test_wifi_configure_nmcli_error(api_client, monkeypatch): @@ -149,7 +149,7 @@ async def mock_configure( resp = api_client.post("/wifi/configure", json={"ssid": "asasd", "foo": "bar"}) assert resp.status_code == 401 body = resp.json() - assert {"message": "no"} == body + assert {"errorCode": "4000", "message": "no"} == body def test_wifi_disconnect(api_client, monkeypatch): @@ -299,7 +299,12 @@ def test_add_key_response(add_key_return, expected_status, expected_body, api_cl @pytest.mark.parametrize( "arg,remove_key_return,expected_status,expected_body", [ - ("12345", None, 404, {"message": "No such key file 12345"}), + ( + "12345", + None, + 404, + {"message": "No such key file 12345", "errorCode": "4000"}, + ), ("54321", "myfile.pem", 200, {"message": "Key file myfile.pem deleted"}), ], ) diff --git a/robot-server/tests/service/legacy/routers/test_settings.py b/robot-server/tests/service/legacy/routers/test_settings.py index d89265025da..7e41c4661fc 100644 --- a/robot-server/tests/service/legacy/routers/test_settings.py +++ b/robot-server/tests/service/legacy/routers/test_settings.py @@ -52,7 +52,10 @@ def test_post_log_level_upstream_fails_reload(api_client): ) body = response.json() assert response.status_code == 500 - assert body == {"message": "Could not reload config: stdout stderr"} + assert body == { + "message": "Could not reload config: stdout stderr", + "errorCode": "4000", + } m.assert_called_once_with(log_level) @@ -207,7 +210,7 @@ def mock_override(pipette_id, fields): ) patch_body = resp.json() assert resp.status_code == 412 - assert patch_body == {"message": "Failed!"} + assert patch_body == {"message": "Failed!", "errorCode": "4000"} def test_available_resets(api_client): @@ -433,14 +436,23 @@ def test_get( @pytest.mark.parametrize( - argnames=["exc", "expected_status"], + argnames=["exc", "expected_status", "message"], argvalues=[ - [ValueError("Failure"), 400], - [advanced_settings.SettingException("Fail", "e"), 500], + [ValueError("Failure"), 400, "ValueError: Failure"], + [ + advanced_settings.SettingException("Fail", "e"), + 500, + "opentrons.config.advanced_settings.SettingException: Fail", + ], ], ) def test_set_err( - api_client, mock_is_restart_required, mock_set_adv_setting, exc, expected_status + api_client, + mock_is_restart_required, + mock_set_adv_setting, + exc, + expected_status, + message, ): mock_is_restart_required.return_value = False @@ -454,7 +466,7 @@ def raiser(i, v): resp = api_client.post("/settings", json={"id": test_id, "value": True}) body = resp.json() assert resp.status_code == expected_status - assert body == {"message": str(exc)} + assert body == {"message": message, "errorCode": "4000"} def test_set(api_client, mock_set_adv_setting, mock_is_restart_required): diff --git a/robot-server/tests/service/session/test_router.py b/robot-server/tests/service/session/test_router.py index 129ab1ebfe4..7a9c94cb31e 100644 --- a/robot-server/tests/service/session/test_router.py +++ b/robot-server/tests/service/session/test_router.py @@ -100,6 +100,7 @@ async def raiser(*args, **kwargs): "id": "UncategorizedError", "detail": "Please attach pipettes before proceeding", "title": "Action Forbidden", + "errorCode": "4000", } ] } @@ -167,6 +168,7 @@ def test_sessions_delete_not_found(sessions_api_client, mock_session_manager): "id": "UncategorizedError", "title": "Resource Not Found", "detail": "Resource type 'session' with id 'check' was not found", + "errorCode": "4000", } ], "links": { @@ -217,6 +219,7 @@ def test_sessions_get_not_found(mock_session_manager, sessions_api_client): "id": "UncategorizedError", "detail": "Resource type 'session' with id '1234' was not found", "title": "Resource Not Found", + "errorCode": "4000", } ], "links": { @@ -306,6 +309,7 @@ def test_sessions_execute_command_no_session(sessions_api_client, mock_session_m "id": "UncategorizedError", "title": "Resource Not Found", "detail": "Resource type 'session' with id '1234' was not found", + "errorCode": "4000", } ], "links": { @@ -402,6 +406,7 @@ async def raiser(*args, **kwargs): "detail": "Cannot do it", "title": "Action Forbidden", "id": "UncategorizedError", + "errorCode": "4000", } ] } @@ -431,6 +436,7 @@ def test_execute_command_session_inactive( "detail": f"Session '{mock_session.meta.identifier}'" f" is not active. Only the active session can " f"execute commands", + "errorCode": "4000", } ] } diff --git a/robot-server/tests/service/tip_length/test_tip_length_management.py b/robot-server/tests/service/tip_length/test_tip_length_management.py index 986f3ece698..628e6b0df29 100644 --- a/robot-server/tests/service/tip_length/test_tip_length_management.py +++ b/robot-server/tests/service/tip_length/test_tip_length_management.py @@ -48,6 +48,7 @@ def test_delete_tip_length_calibration( "title": "Resource Not Found", "detail": "Resource type 'TipLengthCalibration' with id " "'wronghash&fake_pip' was not found", + "errorCode": "4000", } ] } diff --git a/robot-server/tests/system/test_system_router.py b/robot-server/tests/system/test_system_router.py index b9293982ead..68d2e765460 100644 --- a/robot-server/tests/system/test_system_router.py +++ b/robot-server/tests/system/test_system_router.py @@ -49,6 +49,7 @@ def test_raise_system_synchronized_error( "detail": "Cannot set system time; already synchronized with NTP " "or RTC", "title": "Action Forbidden", + "errorCode": "4000", } ] } @@ -75,6 +76,7 @@ def test_raise_system_exception( "id": "UncategorizedError", "detail": "Something went wrong", "title": "Internal Server Error", + "errorCode": "4000", } ] } From c09a3588f9542bd671897cf5403fdea197456052 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 23 Jun 2023 16:22:00 -0400 Subject: [PATCH 5/8] wrong default --- robot-server/robot_server/errors/error_responses.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/robot-server/robot_server/errors/error_responses.py b/robot-server/robot_server/errors/error_responses.py index b05431560e1..06a8c444492 100644 --- a/robot-server/robot_server/errors/error_responses.py +++ b/robot-server/robot_server/errors/error_responses.py @@ -4,7 +4,7 @@ from typing import Any, Dict, Generic, Optional, Sequence, TypeVar, Type from robot_server.service.json_api import BaseResponseBody, ResourceLinks -from opentrons_shared_data.errors import EnumeratedError, PythonException +from opentrons_shared_data.errors import EnumeratedError, PythonException, ErrorCodes class ApiError(Exception): @@ -107,7 +107,7 @@ def get_some_model(): ), ) errorCode: str = Field( - None, + ErrorCodes.GENERAL_ERROR.value.code, description=("The Opentrons error code associated with the error"), ) From 606d0d4788bb6501e43b6c27945ede9511c22931 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 23 Jun 2023 16:50:46 -0400 Subject: [PATCH 6/8] much better --- robot-server/robot_server/errors/error_responses.py | 10 +--------- robot-server/robot_server/errors/global_errors.py | 6 +----- robot-server/tests/errors/test_error_responses.py | 7 +++++++ .../http_api/protocols/test_404.tavern.yaml | 2 ++ .../test_upload_robot_type_rejection.tavern.yaml | 1 + .../tests/integration/test_version_headers.tavern.yaml | 1 + 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/robot-server/robot_server/errors/error_responses.py b/robot-server/robot_server/errors/error_responses.py index 06a8c444492..21fdf1000b3 100644 --- a/robot-server/robot_server/errors/error_responses.py +++ b/robot-server/robot_server/errors/error_responses.py @@ -113,11 +113,7 @@ def get_some_model(): @classmethod def from_exc( - cls: Type["ErrorDetailsT"], - exc: BaseException, - *, - override_defaults: bool = False, - **supplemental_kwargs: Any + cls: Type["ErrorDetailsT"], exc: BaseException, **supplemental_kwargs: Any ) -> "ErrorDetailsT": """Build an ErrorDetails model from an exception. @@ -143,10 +139,6 @@ def _exc_to_meta(exc_val: EnumeratedError) -> Dict[str, Any]: } values["meta"] = _exc_to_meta(checked_exc) - if not override_defaults: - for fieldname, fieldval in cls.__fields__.items(): - if not fieldval.required and fieldval.default is not None: - values.pop(fieldname, None) return cls(**values) def as_error(self, status_code: int) -> ApiError: diff --git a/robot-server/robot_server/errors/global_errors.py b/robot-server/robot_server/errors/global_errors.py index 0236cac207e..73e460854ba 100644 --- a/robot-server/robot_server/errors/global_errors.py +++ b/robot-server/robot_server/errors/global_errors.py @@ -49,14 +49,10 @@ class FirmwareUpdateRequired(ErrorDetails): def from_exc( cls: Type["FirmwareUpdateRequired"], exc: BaseException, - *, - override_defaults: bool = False, **supplemental_kwargs: Any ) -> "FirmwareUpdateRequired": """Build a FirmwareUpdateRequired from a specific exception. Preserves metadata.""" - parent_inst = ErrorDetails.from_exc( - exc, override_defaults=override_defaults, **supplemental_kwargs - ) + parent_inst = ErrorDetails.from_exc(exc, **supplemental_kwargs) inst = FirmwareUpdateRequired(**parent_inst.dict()) if not inst.meta: inst.meta = {"update_url": "/subsystems/update"} diff --git a/robot-server/tests/errors/test_error_responses.py b/robot-server/tests/errors/test_error_responses.py index 0f181dbc36c..088e78274c2 100644 --- a/robot-server/tests/errors/test_error_responses.py +++ b/robot-server/tests/errors/test_error_responses.py @@ -26,6 +26,7 @@ def test_error_details() -> None: "id": "SomeErrorId", "title": "Some Error Title", "detail": "Some error detail", + "errorCode": "4000", }, ) } @@ -50,6 +51,7 @@ def test_error_details_with_meta() -> None: "title": "Some Error Title", "detail": "Some error detail", "source": {"pointer": "/foo/bar/baz"}, + "errorCode": "4000", "meta": {"some": "meta information"}, }, ) @@ -75,6 +77,7 @@ def test_error_response() -> None: id="SomeErrorId", title="Some Error Title", detail="Some error detail", + errorCode="4006", meta={"some": "meta information"}, ), ) @@ -88,6 +91,7 @@ def test_error_response() -> None: "id": "SomeErrorId", "title": "Some Error Title", "detail": "Some error detail", + "errorCode": "4006", "meta": {"some": "meta information"}, }, ) @@ -102,6 +106,7 @@ def test_multi_error_response() -> None: id="SomeErrorId", title="Some Error Title", detail="Some error detail", + errorCode="51231", meta={"some": "meta information"}, ), ErrorDetails( @@ -121,12 +126,14 @@ def test_multi_error_response() -> None: "id": "SomeErrorId", "title": "Some Error Title", "detail": "Some error detail", + "errorCode": "51231", "meta": {"some": "meta information"}, }, { "id": "SomeOtherErrorId", "title": "Some Other Error Title", "detail": "Some other error detail", + "errorCode": "4000", "meta": {"some": "other meta information"}, }, ] diff --git a/robot-server/tests/integration/http_api/protocols/test_404.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_404.tavern.yaml index 4c97ba56aa8..4505de26d0a 100644 --- a/robot-server/tests/integration/http_api/protocols/test_404.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_404.tavern.yaml @@ -15,6 +15,7 @@ stages: - id: ProtocolNotFound title: Protocol Not Found detail: "'Protocol idontexist was not found.'" + errorCode: '4000' --- test_name: Verify error upon DELETE of nonexistent protocol id. @@ -34,3 +35,4 @@ stages: - id: ProtocolNotFound title: Protocol Not Found detail: "'Protocol idontexist was not found.'" + errorCode: '4000' diff --git a/robot-server/tests/integration/http_api/protocols/test_upload_robot_type_rejection.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_upload_robot_type_rejection.tavern.yaml index 2f39d1bea9f..28d61088f07 100644 --- a/robot-server/tests/integration/http_api/protocols/test_upload_robot_type_rejection.tavern.yaml +++ b/robot-server/tests/integration/http_api/protocols/test_upload_robot_type_rejection.tavern.yaml @@ -23,5 +23,6 @@ stages: - id: ProtocolRobotTypeMismatch title: Protocol For Different Robot Type detail: "This protocol is for OT-3 Standard robots. It can't be analyzed or run on this robot, which is an OT-2 Standard." + errorCode: '4000' # TODO(mm, 2022-12-12): Also make sure an OT-3 server rejects OT-2 protocols. diff --git a/robot-server/tests/integration/test_version_headers.tavern.yaml b/robot-server/tests/integration/test_version_headers.tavern.yaml index 7f18f6e0850..45f8926af0c 100644 --- a/robot-server/tests/integration/test_version_headers.tavern.yaml +++ b/robot-server/tests/integration/test_version_headers.tavern.yaml @@ -65,6 +65,7 @@ stages: - id: 'OutdatedAPIVersion' title: 'Requested HTTP API version no longer supported' detail: The requested API version '1' is not supported. 'Opentrons-Version' must be at least '2'. Please upgrade your Opentrons App or other HTTP API client. + errorCode: '4000' headers: Opentrons-Version: '4' Opentrons-Min-Version: '2' From 882e0c74b8f9dfbebca8294a9f1408f355f92f43 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 27 Jun 2023 10:22:18 -0400 Subject: [PATCH 7/8] typo --- shared-data/python/opentrons_shared_data/errors/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared-data/python/opentrons_shared_data/errors/exceptions.py b/shared-data/python/opentrons_shared_data/errors/exceptions.py index 501f4eccf9f..6b2bb305b38 100644 --- a/shared-data/python/opentrons_shared_data/errors/exceptions.py +++ b/shared-data/python/opentrons_shared_data/errors/exceptions.py @@ -479,7 +479,7 @@ def __init__( detail: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build an GripperNotPresentError.""" + """Build a GripperNotPresentError.""" super().__init__(ErrorCodes.GRIPPER_NOT_PRESENT, message, detail, wrapping) From 469046eb3861d7b1ccaef3f2642d4967d6271472 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 27 Jun 2023 13:39:09 -0400 Subject: [PATCH 8/8] test --- .../integration/http_api/runs/test_protocol_run.tavern.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml index 7e709692e14..d8d1aa10cf9 100644 --- a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml @@ -279,3 +279,4 @@ stages: - id: 'ProtocolUsedByRun' title: 'Protocol Used by Run' detail: 'Protocol {protocol_id} is used by a run and cannot be deleted.' + errorCode: '4000'