Skip to content

Commit 439989d

Browse files
authored
Merge pull request #157 from labthings/cleaner-logs-for-errors
Add InvocationError for an error without traceback. Log cancel at info.
2 parents 5b74441 + b171f44 commit 439989d

File tree

5 files changed

+88
-23
lines changed

5 files changed

+88
-23
lines changed

docs/source/actions.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,13 @@ The first is ``self`` (the first positional argument), which is always the
5757
:ref:`dependencies`, which use annotated type hints to tell LabThings to
5858
supply resources needed by the action. Most often, this is a way of accessing
5959
other `.Things` on the same server.
60+
61+
Raising exceptions
62+
------------------
63+
If an action raises an unhandled exception, the action will terminate with an Error
64+
status and LabThings will log the error and the traceback.
65+
66+
In the case where the error has been handled, but the job needs to terminate the action
67+
should raise an InvocationError (or a error which subclasses this). The message from
68+
this exceptions will be logged, but the full traceback will not be logged as this error
69+
has been handled.

src/labthings_fastapi/actions/__init__.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from ..dependencies.invocation import (
3434
CancelHook,
3535
InvocationCancelledError,
36+
InvocationError,
3637
invocation_logger,
3738
)
3839
from ..outputs.blob import BlobIOContextDep, blobdata_to_url_ctx
@@ -247,11 +248,6 @@ def run(self) -> None:
247248
stored. The status is then set to ERROR and the thread terminates.
248249
249250
See `.Invocation.status` for status values.
250-
251-
:raise Exception: any exception raised in the action function will
252-
propagate through this method. Usually, this will just cause the
253-
thread to terminate after setting ``status`` to ``ERROR`` and
254-
saving the exception to ``self._exception``.
255251
"""
256252
try:
257253
self.action.emit_changed_event(self.thing, self._status)
@@ -280,17 +276,22 @@ def run(self) -> None:
280276
self._status = InvocationStatus.COMPLETED
281277
self.action.emit_changed_event(self.thing, self._status)
282278
except InvocationCancelledError:
283-
logger.error(f"Invocation {self.id} was cancelled.")
279+
logger.info(f"Invocation {self.id} was cancelled.")
284280
with self._status_lock:
285281
self._status = InvocationStatus.CANCELLED
286282
self.action.emit_changed_event(self.thing, self._status)
287283
except Exception as e: # skipcq: PYL-W0703
288-
logger.exception(e)
284+
# First log
285+
if isinstance(e, InvocationError):
286+
# Log without traceback
287+
logger.error(e)
288+
else:
289+
logger.exception(e)
290+
# Then set status
289291
with self._status_lock:
290292
self._status = InvocationStatus.ERROR
291293
self._exception = e
292294
self.action.emit_changed_event(self.thing, self._status)
293-
raise e
294295
finally:
295296
with self._status_lock:
296297
self._end_time = datetime.datetime.now()

src/labthings_fastapi/dependencies/invocation.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ class InvocationCancelledError(BaseException):
117117
"""
118118

119119

120+
class InvocationError(RuntimeError):
121+
"""The invocation ended in an anticipated error state.
122+
123+
When this error is raised, action execution stops as expected. The exception will be
124+
logged at error level without a traceback, and the invocation will return with
125+
error status.
126+
127+
Subclass this error for errors that do not need further traceback information
128+
to be provided with the error message in logs.
129+
"""
130+
131+
120132
class CancelEvent(threading.Event):
121133
"""An Event subclass that enables cancellation of actions.
122134

src/labthings_fastapi/exceptions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .dependencies.invocation import (
88
InvocationCancelledError as InvocationCancelledError,
99
)
10+
from .dependencies.invocation import InvocationError as InvocationError
1011

1112

1213
class NotConnectedToServerError(RuntimeError):

tests/test_action_logging.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,73 @@
99
from labthings_fastapi.actions.invocation_model import LogRecordModel
1010

1111

12-
class ThingOne(lt.Thing):
12+
class ThingThatLogsAndErrors(lt.Thing):
1313
LOG_MESSAGES = [
1414
"message 1",
1515
"message 2",
1616
]
1717

1818
@lt.thing_action
19-
def action_one(self, logger: lt.deps.InvocationLogger):
19+
def action_that_logs(self, logger: lt.deps.InvocationLogger):
2020
for m in self.LOG_MESSAGES:
2121
logger.info(m)
2222

23+
@lt.thing_action
24+
def action_with_unhandled_error(self, logger: lt.deps.InvocationLogger):
25+
raise RuntimeError("I was asked to raise this error.")
26+
27+
@lt.thing_action
28+
def action_with_invocation_error(self, logger: lt.deps.InvocationLogger):
29+
raise lt.exceptions.InvocationError("This is an error, but I handled it!")
30+
2331

2432
def test_invocation_logging(caplog):
25-
caplog.set_level(logging.INFO)
26-
server = lt.ThingServer()
27-
server.add_thing(ThingOne(), "/thing_one")
28-
with TestClient(server.app) as client:
29-
r = client.post("/thing_one/action_one")
30-
r.raise_for_status()
31-
invocation = poll_task(client, r.json())
32-
assert invocation["status"] == "completed"
33-
assert len(invocation["log"]) == len(ThingOne.LOG_MESSAGES)
34-
for expected, entry in zip(
35-
ThingOne.LOG_MESSAGES, invocation["log"], strict=True
36-
):
37-
assert entry["message"] == expected
33+
with caplog.at_level(logging.INFO, logger="labthings.action"):
34+
server = lt.ThingServer()
35+
server.add_thing(ThingThatLogsAndErrors(), "/log_and_error_thing")
36+
with TestClient(server.app) as client:
37+
r = client.post("/log_and_error_thing/action_that_logs")
38+
r.raise_for_status()
39+
invocation = poll_task(client, r.json())
40+
assert invocation["status"] == "completed"
41+
assert len(invocation["log"]) == len(ThingThatLogsAndErrors.LOG_MESSAGES)
42+
assert len(invocation["log"]) == len(caplog.records)
43+
for expected, entry in zip(
44+
ThingThatLogsAndErrors.LOG_MESSAGES, invocation["log"], strict=True
45+
):
46+
assert entry["message"] == expected
47+
48+
49+
def test_unhandled_error_logs(caplog):
50+
"""Check that a log with a traceback is raised if there is an unhandled error."""
51+
with caplog.at_level(logging.INFO, logger="labthings.action"):
52+
server = lt.ThingServer()
53+
server.add_thing(ThingThatLogsAndErrors(), "/log_and_error_thing")
54+
with TestClient(server.app) as client:
55+
r = client.post("/log_and_error_thing/action_with_unhandled_error")
56+
r.raise_for_status()
57+
invocation = poll_task(client, r.json())
58+
assert invocation["status"] == "error"
59+
assert len(invocation["log"]) == len(caplog.records) == 1
60+
assert caplog.records[0].levelname == "ERROR"
61+
# There is a traceback
62+
assert caplog.records[0].exc_info is not None
63+
64+
65+
def test_invocation_error_logs(caplog):
66+
"""Check that a log with a traceback is raised if there is an unhandled error."""
67+
with caplog.at_level(logging.INFO, logger="labthings.action"):
68+
server = lt.ThingServer()
69+
server.add_thing(ThingThatLogsAndErrors(), "/log_and_error_thing")
70+
with TestClient(server.app) as client:
71+
r = client.post("/log_and_error_thing/action_with_invocation_error")
72+
r.raise_for_status()
73+
invocation = poll_task(client, r.json())
74+
assert invocation["status"] == "error"
75+
assert len(invocation["log"]) == len(caplog.records) == 1
76+
assert caplog.records[0].levelname == "ERROR"
77+
# There is not a traceback
78+
assert caplog.records[0].exc_info is None
3879

3980

4081
def test_logrecordmodel():

0 commit comments

Comments
 (0)