Skip to content

Commit 73ff0ac

Browse files
authored
Merge pull request #236 from labthings/ThingClient-exceptions
Thing client exceptions
2 parents ca5edfd + ce9e9c1 commit 73ff0ac

5 files changed

Lines changed: 299 additions & 13 deletions

File tree

src/labthings_fastapi/client/__init__.py

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
from pydantic import BaseModel
1717

1818
from .outputs import ClientBlobOutput
19+
from ..exceptions import (
20+
FailedToInvokeActionError,
21+
ServerActionError,
22+
ClientPropertyError,
23+
)
1924

2025
__all__ = ["ThingClient", "poll_invocation"]
2126
ACTION_RUNNING_KEYWORDS = ["idle", "pending", "running"]
@@ -143,10 +148,17 @@ def get_property(self, path: str) -> Any:
143148
to the ``base_url``.
144149
145150
:return: the property's value, as deserialised from JSON.
151+
:raise ClientPropertyError: is raised the property cannot be read.
146152
"""
147-
r = self.client.get(urljoin(self.path, path))
148-
r.raise_for_status()
149-
return r.json()
153+
response = self.client.get(urljoin(self.path, path))
154+
if response.is_error:
155+
detail = response.json().get("detail")
156+
err_msg = "Unknown error"
157+
if isinstance(detail, str):
158+
err_msg = detail
159+
raise ClientPropertyError(f"Failed to get property {path}: {err_msg}")
160+
161+
return response.json()
150162

151163
def set_property(self, path: str, value: Any) -> None:
152164
"""Make a PUT request to set the value of a property.
@@ -155,9 +167,20 @@ def set_property(self, path: str, value: Any) -> None:
155167
to the ``base_url``.
156168
:param value: the property's value. Currently this must be
157169
serialisable to JSON.
170+
:raise ClientPropertyError: is raised the property cannot be set.
158171
"""
159-
r = self.client.put(urljoin(self.path, path), json=value)
160-
r.raise_for_status()
172+
response = self.client.put(urljoin(self.path, path), json=value)
173+
if response.is_error:
174+
detail = response.json().get("detail")
175+
err_msg = "Unknown error"
176+
if isinstance(detail, str):
177+
err_msg = detail
178+
elif (
179+
isinstance(detail, list) and len(detail) and isinstance(detail[0], dict)
180+
):
181+
err_msg = detail[0].get("msg", "Unknown error")
182+
183+
raise ClientPropertyError(f"Failed to get property {path}: {err_msg}")
161184

162185
def invoke_action(self, path: str, **kwargs: Any) -> Any:
163186
r"""Invoke an action on the Thing.
@@ -177,7 +200,9 @@ def invoke_action(self, path: str, **kwargs: Any) -> Any:
177200
178201
:return: the output value of the action.
179202
180-
:raise RuntimeError: is raised if the action does not complete successfully.
203+
:raise FailedToInvokeActionError: if the action fails to start.
204+
:raise ServerActionError: is raised if the action does not complete
205+
successfully.
181206
"""
182207
for k in kwargs.keys():
183208
value = kwargs[k]
@@ -191,9 +216,12 @@ def invoke_action(self, path: str, **kwargs: Any) -> Any:
191216
# Note that the blob will not be uploaded: we rely on the blob
192217
# still existing on the server.
193218
kwargs[k] = {"href": value.href, "media_type": value.media_type}
194-
r = self.client.post(urljoin(self.path, path), json=kwargs)
195-
r.raise_for_status()
196-
invocation = poll_invocation(self.client, r.json())
219+
response = self.client.post(urljoin(self.path, path), json=kwargs)
220+
if response.is_error:
221+
message = _construct_failed_to_invoke_message(path, response)
222+
raise FailedToInvokeActionError(message)
223+
224+
invocation = poll_invocation(self.client, response.json())
197225
if invocation["status"] == "completed":
198226
if (
199227
isinstance(invocation["output"], Mapping)
@@ -206,8 +234,8 @@ def invoke_action(self, path: str, **kwargs: Any) -> Any:
206234
client=self.client,
207235
)
208236
return invocation["output"]
209-
else:
210-
raise RuntimeError(f"Action did not complete successfully: {invocation}")
237+
message = _construct_invocation_error_message(invocation)
238+
raise ServerActionError(message)
211239

212240
def follow_link(self, response: dict, rel: str) -> httpx.Response:
213241
"""Follow a link in a response object, by its `rel` attribute.
@@ -398,3 +426,52 @@ def add_property(cls: type[ThingClient], property_name: str, property: dict) ->
398426
readable=not property.get("writeOnly", False),
399427
),
400428
)
429+
430+
431+
def _construct_failed_to_invoke_message(path: str, response: httpx.Response) -> str:
432+
"""Format an error for ThingClient to raise if an invocation fails to start.
433+
434+
:param path: The path of the action
435+
:param response: The response object from the POST request to start the action.
436+
:return: The message for the raised error
437+
"""
438+
# Default message if we can't process return
439+
message = f"Unknown error when invoking action {path}"
440+
details = response.json().get("detail", [])
441+
442+
if isinstance(details, str):
443+
message = f"Error when invoking action {path}: {details}"
444+
if isinstance(details, list) and len(details) and isinstance(details[0], dict):
445+
loc = details[0].get("loc", [])
446+
loc_str = "" if len(loc) < 2 else f"'{loc[1]}' - "
447+
err_msg = details[0].get("msg", "Unknown Error")
448+
message = f"Error when invoking action {path}: {loc_str}{err_msg}"
449+
return message
450+
451+
452+
def _construct_invocation_error_message(invocation: Mapping[str, Any]) -> str:
453+
"""Format an error for ThingClient to raise if an invocation ends in and error.
454+
455+
:param invocation: The invocation dictionary returned.
456+
:return: The message for the raised error
457+
"""
458+
inv_id = invocation["id"]
459+
action_name = invocation["action"].split("/")[-1]
460+
461+
err_message = "Unknown error"
462+
463+
if len(invocation.get("log", [])) > 0:
464+
last_log = invocation["log"][-1]
465+
err_message = last_log.get("message", err_message)
466+
467+
exception_type = last_log.get("exception_type")
468+
if exception_type is not None:
469+
err_message = f"[{exception_type}]: {err_message}"
470+
471+
traceback = last_log.get("traceback")
472+
if traceback is not None:
473+
err_message += "\n\nSERVER TRACEBACK START:\n\n"
474+
err_message += traceback
475+
err_message += "\n\nSERVER TRACEBACK END\n\n"
476+
477+
return f"Action {action_name} (ID: {inv_id}) failed with error:\n{err_message}"

src/labthings_fastapi/exceptions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,25 @@ class UnsupportedConstraintError(ValueError):
156156
supported arguments. Their meaning is described in the `pydantic.Field`
157157
documentation.
158158
"""
159+
160+
161+
class FailedToInvokeActionError(RuntimeError):
162+
"""The action could not be started.
163+
164+
This error is raised by a `.ThingClient` instance if an action could not be started.
165+
It most commonly occurs because the input to the action could not be converted
166+
to the required type: the error message should give more detail on what's wrong.
167+
"""
168+
169+
170+
class ServerActionError(RuntimeError):
171+
"""The action ended with an error on the server.
172+
173+
This error is raised by a `ThingClient` when an action is successfully invoked on
174+
the server, but does not complete. The error message should include more information
175+
on why this happened.
176+
"""
177+
178+
179+
class ClientPropertyError(RuntimeError):
180+
"""Setting or getting a property via a ThingClient failed."""

src/labthings_fastapi/invocations.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from datetime import datetime
88
from enum import Enum
99
import logging
10+
import traceback
1011
from typing import Optional, Any, Sequence, TypeVar, Generic
1112
import uuid
1213

@@ -42,6 +43,10 @@ class LogRecordModel(BaseModel):
4243
filename: str
4344
created: datetime
4445

46+
# Optional exception info
47+
exception_type: Optional[str] = None
48+
traceback: Optional[str] = None
49+
4550
@model_validator(mode="before")
4651
@classmethod
4752
def generate_message(cls, data: Any) -> Any:
@@ -62,6 +67,11 @@ def generate_message(cls, data: Any) -> Any:
6267
# the invocation.
6368
# This way, you can find and fix the source.
6469
data.message = f"Error constructing message ({e}) from {data!r}."
70+
71+
if data.exc_info:
72+
data.exception_type = data.exc_info[0].__name__
73+
data.traceback = "\n".join(traceback.format_exception(*data.exc_info))
74+
6575
return data
6676

6777

tests/test_blob_output.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from uuid import uuid4
88

99
from fastapi.testclient import TestClient
10-
from httpx import HTTPStatusError
1110
from pydantic_core import PydanticSerializationError
1211
import pytest
1312
import labthings_fastapi as lt
@@ -229,7 +228,9 @@ def test_blob_input(client):
229228
bad_blob = ClientBlobOutput(
230229
media_type="text/plain", href="http://nonexistent.local/totally_bogus"
231230
)
232-
with pytest.raises(HTTPStatusError, match="404 Not Found"):
231+
232+
msg = "Error when invoking action passthrough_blob: Could not find blob ID in href"
233+
with pytest.raises(lt.exceptions.FailedToInvokeActionError, match=msg):
233234
tc.passthrough_blob(blob=bad_blob)
234235

235236
# Check that the same thing works on the server side

tests/test_thing_client.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Test that Thing Client's can call actions and read properties."""
2+
3+
import re
4+
5+
import pytest
6+
import labthings_fastapi as lt
7+
from fastapi.testclient import TestClient
8+
9+
10+
class ThingToTest(lt.Thing):
11+
"""A thing to be tested by using a ThingClient."""
12+
13+
int_prop: int = lt.property(default=1)
14+
float_prop: float = lt.property(default=0.1)
15+
str_prop: str = lt.property(default="foo")
16+
17+
int_prop_read_only: int = lt.property(default=1, readonly=True)
18+
float_prop_read_only: float = lt.property(default=0.1, readonly=True)
19+
str_prop_read_only: str = lt.property(default="foo", readonly=True)
20+
21+
@lt.action
22+
def increment(self) -> None:
23+
"""Increment the counter.
24+
25+
An action with no arguments or return.
26+
"""
27+
self.int_prop += 1
28+
29+
@lt.action
30+
def increment_and_return(self) -> int:
31+
"""Increment the counter and return value.
32+
33+
An action with no arguments, but with a return value
34+
"""
35+
self.int_prop += 1
36+
return self.int_prop
37+
38+
@lt.action
39+
def increment_by_input(self, value: int) -> None:
40+
"""Increment the counter by input value.
41+
42+
An action with an argument but no return.
43+
"""
44+
self.int_prop += value
45+
46+
@lt.action
47+
def increment_by_input_and_return(self, value: int) -> int:
48+
"""Increment the counter by input value and return the new value.
49+
50+
An action with and argument and a return value.
51+
"""
52+
self.int_prop += value
53+
return self.int_prop
54+
55+
@lt.action
56+
def throw_value_error(self) -> None:
57+
"""Throw a value error."""
58+
raise ValueError("This never works!")
59+
60+
61+
@pytest.fixture
62+
def thing_client():
63+
"""Yield a test client connected to a ThingServer."""
64+
server = lt.ThingServer({"test_thing": ThingToTest})
65+
with TestClient(server.app) as client:
66+
yield lt.ThingClient.from_url("/test_thing/", client=client)
67+
68+
69+
def test_reading_and_setting_properties(thing_client):
70+
"""Test reading and setting properties."""
71+
assert thing_client.int_prop == 1
72+
assert thing_client.float_prop == 0.1
73+
assert thing_client.str_prop == "foo"
74+
75+
thing_client.int_prop = 2
76+
thing_client.float_prop = 0.2
77+
thing_client.str_prop = "foo2"
78+
79+
assert thing_client.int_prop == 2
80+
assert thing_client.float_prop == 0.2
81+
assert thing_client.str_prop == "foo2"
82+
83+
# Set a property that doesn't exist.
84+
err = "Failed to get property foobar: Not Found"
85+
with pytest.raises(lt.exceptions.ClientPropertyError, match=err):
86+
thing_client.get_property("foobar")
87+
88+
# Set a property with bad data type.
89+
err = (
90+
"Failed to get property int_prop: Input should be a valid integer, unable to "
91+
"parse string as an integer"
92+
)
93+
with pytest.raises(lt.exceptions.ClientPropertyError, match=err):
94+
thing_client.int_prop = "Bad value!"
95+
96+
97+
def test_reading_and_not_setting_read_only_properties(thing_client):
98+
"""Test reading read_only properties, but failing to set."""
99+
assert thing_client.int_prop_read_only == 1
100+
assert thing_client.float_prop_read_only == 0.1
101+
assert thing_client.str_prop_read_only == "foo"
102+
103+
with pytest.raises(lt.exceptions.ClientPropertyError, match="Method Not Allowed"):
104+
thing_client.int_prop_read_only = 2
105+
with pytest.raises(lt.exceptions.ClientPropertyError, match="Method Not Allowed"):
106+
thing_client.float_prop_read_only = 0.2
107+
with pytest.raises(lt.exceptions.ClientPropertyError, match="Method Not Allowed"):
108+
thing_client.str_prop_read_only = "foo2"
109+
110+
assert thing_client.int_prop_read_only == 1
111+
assert thing_client.float_prop_read_only == 0.1
112+
assert thing_client.str_prop_read_only == "foo"
113+
114+
115+
def test_call_action(thing_client):
116+
"""Test calling an action."""
117+
assert thing_client.int_prop == 1
118+
thing_client.increment()
119+
assert thing_client.int_prop == 2
120+
121+
122+
def test_call_action_with_return(thing_client):
123+
"""Test calling an action with a return."""
124+
assert thing_client.int_prop == 1
125+
new_value = thing_client.increment_and_return()
126+
assert new_value == 2
127+
assert thing_client.int_prop == 2
128+
129+
130+
def test_call_action_with_args(thing_client):
131+
"""Test calling an action."""
132+
assert thing_client.int_prop == 1
133+
thing_client.increment_by_input(value=5)
134+
assert thing_client.int_prop == 6
135+
136+
137+
def test_call_action_with_args_and_return(thing_client):
138+
"""Test calling an action with a return."""
139+
assert thing_client.int_prop == 1
140+
new_value = thing_client.increment_by_input_and_return(value=5)
141+
assert new_value == 6
142+
assert thing_client.int_prop == 6
143+
144+
145+
def test_call_action_wrong_arg(thing_client):
146+
"""Test calling an action with wrong argument."""
147+
err = "Error when invoking action increment_by_input: 'value' - Field required"
148+
149+
with pytest.raises(lt.exceptions.FailedToInvokeActionError, match=err):
150+
thing_client.increment_by_input(input=5)
151+
152+
153+
def test_call_action_wrong_type(thing_client):
154+
"""Test calling an action with wrong argument."""
155+
err = (
156+
"Error when invoking action increment_by_input: 'value' - Input should be a "
157+
"valid integer, unable to parse string as an integer"
158+
)
159+
with pytest.raises(lt.exceptions.FailedToInvokeActionError, match=err):
160+
thing_client.increment_by_input(value="foo")
161+
162+
163+
def test_call_that_errors(thing_client):
164+
"""Test calling an action with wrong argument."""
165+
regex = r"Action throw_value_error \(ID: [0-9a-f\-]*\) failed with error:"
166+
with pytest.raises(lt.exceptions.ServerActionError, match=regex) as exc_info:
167+
thing_client.throw_value_error()
168+
169+
full_message = str(exc_info.value)
170+
assert "[ValueError]: This never works!" in full_message
171+
assert "SERVER TRACEBACK START:" in full_message
172+
assert "SERVER TRACEBACK END" in full_message
173+
assert re.search(
174+
r'File ".*test_thing_client\.py", line \d+, in throw_value_error',
175+
full_message,
176+
)

0 commit comments

Comments
 (0)