Skip to content

Commit

Permalink
feat(api,shared-data): error codes in PE (#12936)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sfoster1 authored Jun 21, 2023
1 parent 70a7b5b commit 5271d4f
Show file tree
Hide file tree
Showing 19 changed files with 904 additions and 112 deletions.
3 changes: 2 additions & 1 deletion api/src/opentrons/protocol_engine/clients/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
#
Expand Down
44 changes: 38 additions & 6 deletions api/src/opentrons/protocol_engine/errors/error_occurrence.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,52 @@
"""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
# for each error type so client may produce better error messages
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."""
Expand All @@ -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()
Loading

0 comments on commit 5271d4f

Please sign in to comment.