Skip to content

Commit 1b24be0

Browse files
Sagas conformance: Python compensation cells stay waiting and lose typed errors (#218)
1 parent 806db4f commit 1b24be0

7 files changed

Lines changed: 168 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "durable-workflow"
7-
version = "0.4.93"
7+
version = "0.4.94"
88
description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/durable_workflow/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3571,6 +3571,8 @@ async def fail_activity_task(
35713571
lease_owner: str,
35723572
message: str,
35733573
failure_type: str | None = None,
3574+
failure_class: str | None = None,
3575+
failure_code: int | None = None,
35743576
stack_trace: str | None = None,
35753577
non_retryable: bool = False,
35763578
details: Any | None = None,
@@ -3586,6 +3588,10 @@ async def fail_activity_task(
35863588
failure: dict[str, Any] = {"message": message}
35873589
if failure_type is not None:
35883590
failure["type"] = failure_type
3591+
if failure_class is not None:
3592+
failure["class"] = failure_class
3593+
if failure_code is not None:
3594+
failure["code"] = failure_code
35893595
if stack_trace is not None:
35903596
failure["stack_trace"] = stack_trace
35913597
if non_retryable:

src/durable_workflow/worker.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,16 @@ def _string_or_none(value: Any) -> str | None:
123123
return value if isinstance(value, str) and value else None
124124

125125

126+
def _exception_class_name(error: BaseException | type[BaseException]) -> str:
127+
cls = error if isinstance(error, type) else type(error)
128+
return f"{cls.__module__}.{cls.__qualname__}"
129+
130+
131+
def _exception_code(error: BaseException) -> int | None:
132+
code = getattr(error, "code", None)
133+
return code if isinstance(code, int) else None
134+
135+
126136
def _is_missing_payload_placeholder(value: Any) -> bool:
127137
return value is None or (isinstance(value, str) and value == "")
128138

@@ -1161,6 +1171,8 @@ async def _run_activity_task(self, task: dict[str, Any]) -> str:
11611171
f"Reinstall durable-workflow with its runtime dependencies."
11621172
),
11631173
failure_type=type(e).__name__,
1174+
failure_class=_exception_class_name(e),
1175+
failure_code=_exception_code(e),
11641176
stack_trace=traceback.format_exc(),
11651177
non_retryable=True,
11661178
)
@@ -1179,6 +1191,8 @@ async def _run_activity_task(self, task: dict[str, Any]) -> str:
11791191
f"Verify the argument bytes match the declared codec and writer schema."
11801192
),
11811193
failure_type=type(e).__name__,
1194+
failure_class=_exception_class_name(e),
1195+
failure_code=_exception_code(e),
11821196
stack_trace=traceback.format_exc(),
11831197
non_retryable=True,
11841198
)
@@ -1225,6 +1239,7 @@ async def _run_activity_task(self, task: dict[str, Any]) -> str:
12251239
lease_owner=self.worker_id,
12261240
message="activity cancelled",
12271241
failure_type="ActivityCancelled",
1242+
failure_class=_exception_class_name(ActivityCancelled),
12281243
non_retryable=True,
12291244
)
12301245
except Exception as fe:
@@ -1239,6 +1254,8 @@ async def _run_activity_task(self, task: dict[str, Any]) -> str:
12391254
lease_owner=self.worker_id,
12401255
message=str(e),
12411256
failure_type=type(e).__name__,
1257+
failure_class=_exception_class_name(e),
1258+
failure_code=_exception_code(e),
12421259
stack_trace=traceback.format_exc(),
12431260
non_retryable=True,
12441261
)
@@ -1254,6 +1271,8 @@ async def _run_activity_task(self, task: dict[str, Any]) -> str:
12541271
lease_owner=self.worker_id,
12551272
message=str(e),
12561273
failure_type=type(e).__name__,
1274+
failure_class=_exception_class_name(e),
1275+
failure_code=_exception_code(e),
12571276
stack_trace=traceback.format_exc(),
12581277
)
12591278
except Exception as fe:

src/durable_workflow/workflow.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,8 @@ class FailWorkflow:
577577

578578
message: str
579579
exception_type: str | None = None
580+
exception_class: str | None = None
581+
exception: dict[str, Any] | None = None
580582
non_retryable: bool = False
581583

582584
def to_server_command(
@@ -593,6 +595,10 @@ def to_server_command(
593595
}
594596
if self.exception_type is not None:
595597
cmd["exception_type"] = self.exception_type
598+
if self.exception_class is not None:
599+
cmd["exception_class"] = self.exception_class
600+
if self.exception is not None:
601+
cmd["exception"] = self.exception
596602
if self.non_retryable:
597603
cmd["non_retryable"] = True
598604
return cmd
@@ -1870,6 +1876,41 @@ def _fail_update_from_exception(update_id: str, prefix: str, exc: Exception) ->
18701876
)
18711877

18721878

1879+
def _exception_class_name(exc: BaseException) -> str:
1880+
return f"{type(exc).__module__}.{type(exc).__qualname__}"
1881+
1882+
1883+
def _fail_workflow_from_exception(exc: BaseException, *, prefix: str | None = None) -> FailWorkflow:
1884+
message = str(exc) or type(exc).__name__
1885+
if prefix:
1886+
message = f"{prefix}: {message}"
1887+
1888+
exception_type = type(exc).__name__
1889+
exception_class = _exception_class_name(exc)
1890+
exception: dict[str, Any] | None = None
1891+
cause = exc.__cause__
1892+
activity_failure = exc if isinstance(exc, ActivityFailed) else cause if isinstance(cause, ActivityFailed) else None
1893+
if isinstance(activity_failure, ActivityFailed):
1894+
exception_type = activity_failure.exception_type or type(activity_failure).__name__
1895+
exception_class = activity_failure.exception_class or _exception_class_name(activity_failure)
1896+
exception = {
1897+
"type": exception_type,
1898+
"class": exception_class,
1899+
"message": message,
1900+
}
1901+
if activity_failure.activity_type is not None:
1902+
exception["activity_type"] = activity_failure.activity_type
1903+
if activity_failure.activity_attempt_id is not None:
1904+
exception["activity_attempt_id"] = activity_failure.activity_attempt_id
1905+
1906+
return FailWorkflow(
1907+
message=message,
1908+
exception_type=exception_type,
1909+
exception_class=exception_class,
1910+
exception=exception,
1911+
)
1912+
1913+
18731914
def _history_event_type(event: Mapping[str, Any]) -> str | None:
18741915
value = event.get("event_type") or event.get("type")
18751916
return value if isinstance(value, str) and value else None
@@ -2781,9 +2822,9 @@ def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
27812822
try:
27822823
satisfied = bool(cmd.predicate())
27832824
except Exception as exc:
2784-
return _state([FailWorkflow(
2785-
message=f"wait_condition predicate raised: {exc}",
2786-
exception_type=type(exc).__name__,
2825+
return _state([_fail_workflow_from_exception(
2826+
exc,
2827+
prefix="wait_condition predicate raised",
27872828
)])
27882829
if resolution == "satisfied":
27892830
if has_reopened_same_wait:
@@ -2848,14 +2889,8 @@ def _terminal_state(value: Any, *, include_pending: bool) -> _ReplayState:
28482889
except Exception as exc:
28492890
if isinstance(exc, NonDeterministicReplayError):
28502891
raise
2851-
return _state([FailWorkflow(
2852-
message=str(exc),
2853-
exception_type=type(exc).__name__,
2854-
)])
2892+
return _state([_fail_workflow_from_exception(exc)])
28552893
except NonDeterministicReplayError:
28562894
raise
28572895
except Exception as exc:
2858-
return _state([FailWorkflow(
2859-
message=str(exc),
2860-
exception_type=type(exc).__name__,
2861-
)])
2896+
return _state([_fail_workflow_from_exception(exc)])

tests/test_client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2155,7 +2155,7 @@ async def test_get_schedule_history_matches_polyglot_fixture(self, client: Clien
21552155

21562156
wire_events = fixture["response_body"]["events"]
21572157
assert len(page.events) == len(wire_events)
2158-
for parsed, wire in zip(page.events, wire_events):
2158+
for parsed, wire in zip(page.events, wire_events, strict=True):
21592159
assert parsed.id == wire["id"]
21602160
assert parsed.sequence == wire["sequence"]
21612161
assert parsed.event_type == wire["event_type"]
@@ -2511,11 +2511,17 @@ async def test_body_shape(self, client: Client) -> None:
25112511
activity_attempt_id="a1",
25122512
lease_owner="w1",
25132513
message="activity error",
2514+
failure_type="TypedActivityError",
2515+
failure_class="app.activities.TypedActivityError",
2516+
failure_code=409,
25142517
non_retryable=True,
25152518
)
25162519
body = mock.call_args.kwargs.get("json") or mock.call_args[1].get("json")
25172520
assert "failure" in body
25182521
assert body["failure"]["message"] == "activity error"
2522+
assert body["failure"]["type"] == "TypedActivityError"
2523+
assert body["failure"]["class"] == "app.activities.TypedActivityError"
2524+
assert body["failure"]["code"] == 409
25192525
assert body["failure"]["non_retryable"] is True
25202526

25212527
@pytest.mark.asyncio

tests/test_replay.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
152152
raise ValueError("something went wrong")
153153

154154

155+
@workflow.defn(name="typed-compensation-failure-workflow")
156+
class TypedCompensationFailureWorkflow:
157+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
158+
try:
159+
yield ctx.schedule_activity("charge_card", [])
160+
except ActivityFailed:
161+
try:
162+
yield ctx.schedule_activity("cancel_flight", [])
163+
except ActivityFailed as exc:
164+
failure_type = exc.exception_type or type(exc).__name__
165+
raise RuntimeError(f"compensation failed for cancel_flight: {failure_type}: {exc}") from exc
166+
return "completed"
167+
168+
155169
def _activity_completed_event(sequence: int, activity_type: str) -> dict:
156170
return {
157171
"event_type": "ActivityCompleted",
@@ -668,13 +682,59 @@ def test_exception_produces_fail_command(self) -> None:
668682
assert isinstance(cmd, FailWorkflow)
669683
assert "something went wrong" in cmd.message
670684
assert cmd.exception_type == "ValueError"
685+
assert cmd.exception_class == "builtins.ValueError"
671686

672687
def test_fail_server_command_shape(self) -> None:
673688
history = [{"event_type": "ActivityCompleted", "payload": {"result": '"ok"'}}]
674689
outcome = replay(FailingWorkflow, history, [])
675690
server_cmd = outcome.commands[0].to_server_command("q")
676691
assert server_cmd["type"] == "fail_workflow"
677692
assert "something went wrong" in server_cmd["message"]
693+
assert server_cmd["exception_class"] == "builtins.ValueError"
694+
695+
def test_typed_compensation_activity_failure_reaches_terminal_command(self) -> None:
696+
history = [
697+
{
698+
"event_type": "ActivityFailed",
699+
"payload": {
700+
"sequence": 1,
701+
"activity_type": "charge_card",
702+
"exception_type": "PlannedSagaFailure",
703+
"exception_class": "sagas.PlannedSagaFailure",
704+
"message": "planned saga failure",
705+
},
706+
},
707+
{
708+
"event_type": "ActivityFailed",
709+
"payload": {
710+
"sequence": 2,
711+
"activity_type": "cancel_flight",
712+
"activity_attempt_id": "attempt-cancel-flight",
713+
"exception_type": "TypedCancelFlightError",
714+
"exception_class": "__main__.TypedCancelFlightError",
715+
"message": "cancel_flight typed compensation failure",
716+
},
717+
},
718+
]
719+
720+
outcome = replay(TypedCompensationFailureWorkflow, history, [])
721+
722+
assert len(outcome.commands) == 1
723+
cmd = outcome.commands[0]
724+
assert isinstance(cmd, FailWorkflow)
725+
assert cmd.exception_type == "TypedCancelFlightError"
726+
assert cmd.exception_class == "__main__.TypedCancelFlightError"
727+
assert cmd.exception is not None
728+
assert cmd.exception["type"] == "TypedCancelFlightError"
729+
assert cmd.exception["message"] == cmd.message
730+
assert cmd.exception["activity_type"] == "cancel_flight"
731+
assert cmd.exception["activity_attempt_id"] == "attempt-cancel-flight"
732+
assert "cancel_flight typed compensation failure" in cmd.message
733+
734+
server_cmd = cmd.to_server_command("q")
735+
assert server_cmd["exception_type"] == "TypedCancelFlightError"
736+
assert server_cmd["exception_class"] == "__main__.TypedCancelFlightError"
737+
assert server_cmd["exception"]["message"] == cmd.message
678738

679739

680740
class TestCompleteWorkflowCommand:

tests/test_worker.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
)
4040

4141

42+
class TypedCancelFlightError(Exception):
43+
code = 712
44+
45+
4246
@workflow.defn(name="test-wf")
4347
class TestWorkflow:
4448
def run(self, ctx, *args): # type: ignore[no-untyped-def]
@@ -2000,6 +2004,31 @@ def failing_act() -> None:
20002004
call_kwargs = mock_client.fail_activity_task.call_args.kwargs
20012005
assert "boom" in call_kwargs["message"]
20022006
assert call_kwargs["failure_type"] == "RuntimeError"
2007+
assert call_kwargs["failure_class"] == "builtins.RuntimeError"
2008+
2009+
@pytest.mark.asyncio
2010+
async def test_activity_exception_reports_typed_failure_metadata(self, mock_client: AsyncMock) -> None:
2011+
@activity.defn(name="cancel-flight")
2012+
def cancel_flight() -> None:
2013+
raise TypedCancelFlightError("cancel_flight typed compensation failure")
2014+
2015+
worker = Worker(mock_client, task_queue="q1", workflows=[], activities=[cancel_flight])
2016+
task = {
2017+
"task_id": "at-typed",
2018+
"activity_attempt_id": "aa-typed",
2019+
"activity_type": "cancel-flight",
2020+
"arguments": "[]",
2021+
"payload_codec": "json",
2022+
}
2023+
2024+
await worker._run_activity_task(task)
2025+
2026+
mock_client.fail_activity_task.assert_called_once()
2027+
call_kwargs = mock_client.fail_activity_task.call_args.kwargs
2028+
assert call_kwargs["failure_type"] == "TypedCancelFlightError"
2029+
assert call_kwargs["failure_class"] == f"{__name__}.TypedCancelFlightError"
2030+
assert call_kwargs["failure_code"] == 712
2031+
assert "cancel_flight typed compensation failure" in call_kwargs["message"]
20032032

20042033

20052034
class TestWorkerInterceptors:

0 commit comments

Comments
 (0)