From 46eca95bb91b832d196d97b2f023ff7ad40d2eaf Mon Sep 17 00:00:00 2001 From: jinliyl <6469360+jinliyl@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:54:54 +0800 Subject: [PATCH] fix(job): preserve nested exception details (#544) --- reme/components/job/base_job.py | 21 ++++++++++++++++++++- tests/unit/test_job.py | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/reme/components/job/base_job.py b/reme/components/job/base_job.py index bb026a6c..32475ddd 100644 --- a/reme/components/job/base_job.py +++ b/reme/components/job/base_job.py @@ -13,6 +13,25 @@ if TYPE_CHECKING: from ...steps import BaseStep +def _describe_exception(exc: BaseException) -> str: + """Render an exception and its causes without dropping empty messages.""" + parts: list[str] = [] + current: BaseException | None = exc + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + message = str(current).strip() + name = type(current).__name__ + parts.append(f"{name}: {message}" if message else name) + if current.__cause__ is not None: + current = current.__cause__ + elif not current.__suppress_context__: + current = current.__context__ + else: + current = None + return " <- ".join(parts) + + @R.register("base") class BaseJob(BaseComponent): """Job that executes steps sequentially and returns a Response.""" @@ -75,5 +94,5 @@ class BaseJob(BaseComponent): except Exception as e: self.logger.exception(f"Failed to execute job: {e}") context.response.success = False - context.response.answer = str(e) + context.response.answer = _describe_exception(e) return context.response diff --git a/tests/unit/test_job.py b/tests/unit/test_job.py index 76b3a53e..7a0b7a2c 100644 --- a/tests/unit/test_job.py +++ b/tests/unit/test_job.py @@ -71,6 +71,26 @@ def test_call_captures_exception(): asyncio.run(run()) +def test_call_preserves_cause_when_outer_exception_message_is_empty(): + async def run(): + async def failing_step(_context): + try: + raise RuntimeError("connection reset by peer") + except RuntimeError as exc: + raise ConnectionError() from exc + + job = BaseJob(name="j") + job.app_context = MagicMock() + job.step_specs = [] + job._build_steps = lambda: [failing_step] + + response = await job() + assert response.success is False + assert response.answer == ("ConnectionError <- RuntimeError: connection reset by peer") + + asyncio.run(run()) + + def test_call_runs_steps_in_order(): async def run(): call_order = []