-
Notifications
You must be signed in to change notification settings - Fork 180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(robot-server): Present error codes in error responses #12969
Changes from all commits
deb5fd7
7f32f36
d7163e2
ab7290a
c09a358
606d0d4
882e0c7
469046e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,10 @@ | ||
"""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 EnumeratedError, PythonException, ErrorCodes | ||
|
||
|
||
class ApiError(Exception): | ||
|
@@ -105,6 +106,40 @@ def get_some_model(): | |
"occurrence of the error" | ||
), | ||
) | ||
errorCode: str = Field( | ||
ErrorCodes.GENERAL_ERROR.value.code, | ||
description=("The Opentrons error code associated with the error"), | ||
) | ||
|
||
@classmethod | ||
def from_exc( | ||
cls: Type["ErrorDetailsT"], exc: BaseException, **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) | ||
return cls(**values) | ||
|
||
def as_error(self, status_code: int) -> ApiError: | ||
"""Serial this ErrorDetails as an ApiError from an ErrorResponse.""" | ||
|
@@ -121,12 +156,29 @@ class LegacyErrorResponse(BaseErrorBody): | |
..., | ||
description="A human-readable message describing the error.", | ||
) | ||
errorCode: str = Field( | ||
..., 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for a reason I can't be bothered with it comes with a trailing newline |
||
) | ||
|
||
|
||
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=( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,39 +1,61 @@ | ||
"""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 | ||
|
||
|
||
class UnexpectedError(ErrorDetails): | ||
"""An error returned when an unhandled exception occurs.""" | ||
|
||
id: Literal["UnexpectedError"] = "UnexpectedError" | ||
title: str = "Unexpected Internal Error" | ||
errorCode: str = ErrorCodes.GENERAL_ERROR.value.code | ||
|
||
|
||
class BadRequest(ErrorDetails): | ||
"""An error returned when the framework rejects the request.""" | ||
|
||
id: Literal["BadRequest"] = "BadRequest" | ||
title: str = "Bad Request" | ||
errorCode: str = ErrorCodes.GENERAL_ERROR.value.code | ||
|
||
|
||
class InvalidRequest(ErrorDetails): | ||
"""An error returned when the request fails validation.""" | ||
|
||
id: Literal["InvalidRequest"] = "InvalidRequest" | ||
title: str = "Invalid Request" | ||
errorCode: str = ErrorCodes.GENERAL_ERROR.value.code | ||
|
||
|
||
class IDNotFound(ErrorDetails): | ||
"""An error returned when an ID is specified incorrectly.""" | ||
|
||
id: Literal["IDNotFound"] = "IDNotFound" | ||
title: str = "ID Not Found" | ||
errorCode: str = ErrorCodes.GENERAL_ERROR.value.code | ||
|
||
|
||
class FirmwareUpdateRequired(ErrorDetails): | ||
"""An error returned when a command requests to interact with hardware that requires an update.""" | ||
|
||
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, | ||
**supplemental_kwargs: Any | ||
) -> "FirmwareUpdateRequired": | ||
"""Build a FirmwareUpdateRequired from a specific exception. Preserves metadata.""" | ||
parent_inst = ErrorDetails.from_exc(exc, **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 | ||
Comment on lines
+48
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me make sure I understand what's going on here:
Is that right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah pretty much. It's pretty equally metadata, so it doesn't really do any harm to put it in there next to all the other stuff |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
Comment on lines
-124
to
+122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very very very happy that we're making this broken This There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, and it's worse in some other places like the exception handlers, where we
What I'd really prefer is that exceptions get turned into models and models get sent, without going back into ApiErrors. |
||
|
||
return await PydanticResponse.create( | ||
content=SimpleBody.construct(data=action), | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the type be
Optional[str]
since there's a default ofNone
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's weird that it allows that. i should change that to default to 4000 anyway