Skip to content

Commit 2646eb5

Browse files
Merge pull request #76 from miniscope/error-tracebacks
preserve original traceback in a note
2 parents e70a763 + 40a506e commit 2646eb5

File tree

3 files changed

+48
-7
lines changed

3 files changed

+48
-7
lines changed

src/noob/network/message.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ class AnnounceValue(TypedDict):
8686
nodes: dict[str, IdentifyValue]
8787

8888

89+
class ErrorValue(TypedDict):
90+
err_type: type[Exception]
91+
err_args: tuple
92+
traceback: str
93+
94+
8995
class AnnounceMsg(Message):
9096
"""Command node 'announces' identities of other peers and the events they emit"""
9197

@@ -132,7 +138,7 @@ class ErrorMsg(Message):
132138
"""An error occurred in one of the processing nodes"""
133139

134140
type_: Literal[MessageType.error] = Field(MessageType.error, alias="type")
135-
value: Picklable[Exception]
141+
value: Picklable[ErrorValue]
136142

137143
model_config = ConfigDict(arbitrary_types_allowed=True)
138144

src/noob/runner/zmq.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import os
2727
import signal
2828
import threading
29+
import traceback
2930
from collections import defaultdict
3031
from collections.abc import Callable, Generator
3132
from dataclasses import dataclass, field
@@ -54,6 +55,7 @@
5455
AnnounceMsg,
5556
AnnounceValue,
5657
ErrorMsg,
58+
ErrorValue,
5759
EventMsg,
5860
IdentifyMsg,
5961
IdentifyValue,
@@ -563,8 +565,20 @@ def on_stop(self, msg: StopMsg) -> None:
563565
os.kill(pid, signal.SIGTERM)
564566

565567
def error(self, err: Exception) -> None:
566-
self.logger.debug("Throwing error in main runner: %s", err)
567-
msg = ErrorMsg(node_id=self.spec.id, value=err)
568+
"""
569+
Capture the error and traceback context from an exception using
570+
:class:`traceback.TracebackException` and send to command node to re-raise
571+
"""
572+
tbexception = "\n".join(traceback.format_tb(err.__traceback__))
573+
self.logger.debug("Throwing error in main runner: %s", tbexception)
574+
msg = ErrorMsg(
575+
node_id=self.spec.id,
576+
value=ErrorValue(
577+
err_type=type(err),
578+
err_args=err.args,
579+
traceback=tbexception,
580+
),
581+
)
568582
self._dealer.send_multipart([msg.to_bytes()])
569583

570584

@@ -583,7 +597,7 @@ class ZMQRunner(TubeRunner):
583597
_running: EventType = field(default_factory=mp.Event)
584598
_return_node: Return | None = None
585599
_init_lock: threading.Lock = field(default_factory=threading.Lock)
586-
_to_throw: Exception | None = None
600+
_to_throw: ErrorValue | None = None
587601

588602
@property
589603
def running(self) -> bool:
@@ -708,14 +722,25 @@ def _handle_error(self, msg: ErrorMsg) -> None:
708722
self.tube.scheduler.end_epoch(self._current_epoch)
709723

710724
def _throw_error(self) -> None:
711-
err = self._to_throw
712-
if err is None:
725+
errval = self._to_throw
726+
if errval is None:
713727
return
728+
# clear instance object and store locally, we aren't locked here.
714729
self._to_throw = None
715730
self._logger.debug(
716731
"Deinitializing before throwing error",
717732
)
718733
self.deinit()
734+
735+
# add the traceback as a note,
736+
# sort of the best we can do without using tblib
737+
err = errval["err_type"](*errval["err_args"])
738+
tb_message = "\nError re-raised from node runner process\n\n"
739+
tb_message += "Original traceback:\n"
740+
tb_message += "-" * 20 + "\n"
741+
tb_message += errval["traceback"]
742+
err.add_note(tb_message)
743+
719744
raise err
720745

721746
def enable_node(self, node_id: str) -> None:

tests/test_runners/test_zmq.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,15 @@ def test_error_reporting():
4747
"""When a zmq runner node has an error, it sends it back to the command node"""
4848
tube = Tube.from_specification("testing-error")
4949
runner = ZMQRunner(tube)
50-
with pytest.raises(ValueError, match="This node just emits errors"):
50+
with pytest.raises(ValueError) as exc_info:
5151
runner.process()
52+
53+
assert exc_info.type is ValueError
54+
# original error message
55+
assert str(exc_info.value) == "This node just emits errors"
56+
# additional information in the notes
57+
note = exc_info.value.__notes__[0]
58+
# notice that this error was raised in another process
59+
assert "Error re-raised from node runner process" in note
60+
# the location of the original exception
61+
assert "testing/nodes.py" in note

0 commit comments

Comments
 (0)