Better errors for bad return values#333
Conversation
9c256a9 to
e945edc
Compare
Barecheck - Code coverage reportTotal: 96.46%Your code coverage diff: -0.41% ▾ Uncovered files and lines |
e945edc to
1a60cdd
Compare
|
This branch has fallen foul of separate input and output schemas in Pydantic. That seems to mean the OpenAPI docs are missing schemas for GET requests to properties. Disabling schema-splitting fixes the problem, so I'll likely include that in this PR. |
Action outputs are serialized using a pydantic model. We now create (and validate) this model instance in the action thread, rather than in the response handler returning it to the user. This means that validation errors will now be caught and logged, and there won't be loads of ASGI/routing/FastAPI stuff in the stack trace. It does not yet fix the problem of un-JSONable types being returned, because `pydantic` will allow `Any` to be wrapped in a `RootModel` which validates fine, and fails at serialisation time.
The stack trace is not useful when an action returns an invalid value, as it only tells us about LabThings code. The error is still logged, but there's no stack trace.
This commit: * Adds an error handler for PydanticSerializationError * Fixes a race condition in invocation responses that could result in an output being present before the response marks the invocation as complete * Handles HTTP errors when polling actions in ThingClient * Ensures that invocation models attach useful debug info to serialization errors (they add the invocation ID and action path).
Depending on whether the action fails before or after the initial response is sent to the client, we get a different exception (with the same message). This commit will accept either kind of failure so long as it includes the same message. This should prevent intermittent test failures: there's nothing to synchronise the action thread and the HTTP response, and it's not feasible to add that now.
This commit moves `wrap_plain_types_in_rootmodel` into the `RootModelWrapper` class as a class method, and adds better error handling. This now returns class and attribute references, or file and line number in the case of functions. Currently these errors are raised if unserializable types are declared. A future commit will raise similar errors at runtime if unserialisable values are found.
This commit moves both validation and serialisation of properties into LabThings code, so the exception may be handled properly. If this looks good, we may want to make a custom Response class.
This was causing properties to have no schema for their GET endpoint. It's detailed at https://fastapi.tiangolo.com/how-to/separate-openapi-schemas/#same-schema-for-input-and-output-models-in-docs
This commit removes the complicated wrapper-based error handling in favour of a function. This approach is specific (only catches ValidationError/SerializationError directly related to user code) and avoids our custom errors being re-wrapped by `pydantic`.
This makes a few changes to ensure we handle serialization errors better: 1. Invocations now only return their input, output, log, and links when accessed individually. This avoids the possibility of serialization errors in lists of invocations (and should give a decent performance boost). 2. Endpoints that return values from downstream code (property reads or action outputs) now return `Response` directly, and use `serialize_from_user_code` to apply sensible error handling. 3. Everywhere I've used `serialize_from_user_code` I've specified the model in the FastAPI decorator, and ensured the exception is handled, logged, and re-raised as an HTTPException. I've split up `response_from_user_code` to handle validation and serialization separately: that fits better with the way actions are structured.
These may be used by some clients to find the full model, so they're useful here.
This checks that we get the correct errors when accessing bad values over HTTP. Note that an invocation with an unserializable return value will raise an error if it is accessed individually, but won't cause the global endpoints to fail.
55f5a21 to
26852c6
Compare
| # If there's only one string, assume it's a message and append | ||
| self.args = (self.args[0] + "\n" + message,) | ||
| else: | ||
| # If there are multiple arguments, add this as a further one |
There was a problem hiding this comment.
| # If there are multiple arguments, add this as a further one | |
| # If there are multiple or no arguments, add this as a further one |
|
|
||
|
|
||
| class UnserializableTypeError(CausedByUserCodeError, TypeError): | ||
| r"""A type has been specified that can't be serialized to JSON. |
There was a problem hiding this comment.
| r"""A type has been specified that can't be serialized to JSON. | |
| r"""A type has been specified that can't be serialised to JSON. |
Just to be consistent with the docstring for InvalidReturnValueError
| r"""A type has been specified that can't be serialized to JSON. | ||
|
|
||
| This error generally means a property or action has a type that cannot be | ||
| serialized to JSON. This might be an instance of a custom class, or another |
There was a problem hiding this comment.
| serialized to JSON. This might be an instance of a custom class, or another | |
| serialised to JSON. This might be an instance of a custom class, or another |
Just to be consistent with the docstring for InvalidReturnValueError
| def summary_model(self) -> InvocationSummary: | ||
| """Generate a summary of the invocation suitable for HTTP. | ||
|
|
||
| :return: a `InvocationSummary` representing this `Invocation`. |
There was a problem hiding this comment.
| :return: a `InvocationSummary` representing this `Invocation`. | |
| :return: an `InvocationSummary` representing this `Invocation`. |
| output_model_instance = validate_from_user_code( | ||
| model=action.output_model, | ||
| value=ret, | ||
| description=f"the output of '{self.thing.name}.{action.name}'", | ||
| code=action.func, | ||
| ) | ||
|
|
||
| with self._status_lock: | ||
| self._output_model_instance = output_model_instance | ||
| self._status = InvocationStatus.COMPLETED | ||
| action.emit_changed_event(self.thing, self._status.value) |
There was a problem hiding this comment.
why has this been moved outside of the with action.context_for_func(thing): context?
|
|
||
|
|
||
| class Unjsonable: | ||
| """A class that pydantic can't serialize.""" |
There was a problem hiding this comment.
| """A class that pydantic can't serialize.""" | |
| """A class that pydantic can't serialise.""" |
|
|
||
|
|
||
| def test_bad_type(): | ||
| """Test an obviously un-serializable type raises an error.""" |
There was a problem hiding this comment.
| """Test an obviously un-serializable type raises an error.""" | |
| """Test an obviously un-serialisable type raises an error.""" |
| class BrokenThing(lt.Thing): | ||
| broken: Unjsonable | None = lt.property(default=None) | ||
|
|
||
| with pytest.raises(UnserializableTypeError, match="BrokenThing.broken"): | ||
| _ = BrokenThing.properties["broken"].model |
There was a problem hiding this comment.
Can we add some comments explaining what's happening here in a bit more detail?
| ): | ||
| _ = tc.intprop | ||
|
|
||
| # The second property won't serialize |
There was a problem hiding this comment.
| # The second property won't serialize | |
| # The second property won't serialise |
| assert response.status_code == 503 # There's no output as it failed. | ||
|
|
||
| # Here, the type hint is vague so it validates OK, but it can't | ||
| # serialize. |
There was a problem hiding this comment.
| # serialize. | |
| # serialise. |
There are quite a few places in the codebase where values that come from downstream code are returned over HTTP. As a rule, these values are returned from endpoint functions, and FastAPI then attempts to serialise them using
pydantic. If the value cannot be serialized or validated by the model (because we need a model instance to serialize from, and we can only get one by validating the input data), we get an ugly error. "ugly" in this case means there's an enormous stack trace, almost all of which refers tofastapiorstarletteorpydanticand none of which tells you where in the code you actually wrote the problem arose.This merge request attempts to address that problem, by taking control of how values are serialized. This applies to:
The first change is that we now instantiate models ourselves, rather than relying on FastAPI to do it. This ensures we can catch and handle any validation errors.
The second change is that we now serialize those models to JSON and return a
Responseinstead of returning a model instance. This allows us to catch serialization errors.I've added some helper functions to keep this neat, and also to ensure we provide useful hints in the error message as to where in the downstream code the problem may have occurred - for example, actions will give the name of the action, and the file and line number where the function was defined.
All together, these changes mean that, when a bad value is passed back to a client, we respond with a helpful
500HTTP error response and log a useful error on the server.There are more places in LabThings where error handling could be improved: the biggest one I can think of is the thing description, where bad types or bad default values can lead to hard-to-debug errors. I have deliberately not swallowed all validation/serialization errors, just the ones for which we can obviously point to a cause.