durable-workflow 2.4.1__tar.gz → 2.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {durable_workflow-2.4.1/src/durable_workflow.egg-info → durable_workflow-2.4.3}/PKG-INFO +1 -1
  2. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/pyproject.toml +3 -3
  3. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/client.py +16 -2
  5. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/errors.py +30 -0
  6. {durable_workflow-2.4.1 → durable_workflow-2.4.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_client.py +48 -0
  8. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_errors.py +53 -0
  9. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_pending_operation_replay.py +40 -1
  10. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_sync.py +29 -1
  11. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/LICENSE +0 -0
  12. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/README.md +0 -0
  13. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/setup.cfg +0 -0
  14. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_activity_process.py +0 -0
  15. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_avro.py +0 -0
  16. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cancellation_scope.py +0 -0
  17. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  18. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  19. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_prepared_local_activity.py +0 -0
  20. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/activity.py +0 -0
  21. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/auth_composition.py +0 -0
  22. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/cancellation.py +0 -0
  23. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_storage.py +0 -0
  24. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_task_input.py +0 -0
  25. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_task_result.py +0 -0
  26. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/history_bundle_verify.py +0 -0
  27. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/interceptors.py +0 -0
  28. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/invocable.py +0 -0
  29. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/metrics.py +0 -0
  30. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/nexus.py +0 -0
  31. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/py.typed +0 -0
  32. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/python_conformance.py +0 -0
  33. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/replay_conformance.py +0 -0
  34. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/replay_verify.py +0 -0
  35. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/retry_policy.py +0 -0
  36. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/serializer.py +0 -0
  37. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/sync.py +0 -0
  38. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/testing.py +0 -0
  39. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/worker.py +0 -0
  40. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/worker_session.py +0 -0
  41. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/workflow.py +0 -0
  42. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  43. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  44. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  45. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  46. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/requires.txt +0 -0
  47. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
  48. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_activity_cancellation_policies.py +0 -0
  49. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_activity_context.py +0 -0
  50. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_activity_process.py +0 -0
  51. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_auth_composition.py +0 -0
  52. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_avro_value_benchmark.py +0 -0
  53. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_avro_value_protocol.py +0 -0
  54. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_context.py +0 -0
  55. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_remaining_time.py +0 -0
  56. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_admission.py +0 -0
  57. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_authoring.py +0 -0
  58. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_boundary.py +0 -0
  59. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_opening.py +0 -0
  60. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_child_workflow_policies.py +0 -0
  61. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_ci_checkout.py +0 -0
  62. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_ci_integration_endpoint.py +0 -0
  63. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_client_timeouts.py +0 -0
  64. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_codec_regression_corpus.py +0 -0
  65. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  66. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_groups.py +0 -0
  67. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_history.py +0 -0
  68. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_replay.py +0 -0
  69. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_control_plane_parity_fixtures.py +0 -0
  70. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation.py +0 -0
  71. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_client.py +0 -0
  72. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_worker.py +0 -0
  73. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_remote_worker.py +0 -0
  74. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_docstring_cross_references.py +0 -0
  75. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_durable_selection.py +0 -0
  76. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_external_storage.py +0 -0
  77. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_external_task_input.py +0 -0
  78. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_external_task_result.py +0 -0
  79. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_golden_history_replay.py +0 -0
  80. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_history_bundle_verify.py +0 -0
  81. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_history_event_contract.py +0 -0
  82. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_invocable.py +0 -0
  83. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_message_streams.py +0 -0
  84. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_metrics.py +0 -0
  85. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_order_processing_example.py +0 -0
  86. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_parallel_completion_order_replay.py +0 -0
  87. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_parallel_saga_authoring.py +0 -0
  88. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_client.py +0 -0
  89. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_groups.py +0 -0
  90. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_policies.py +0 -0
  91. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_worker.py +0 -0
  92. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_public_boundary_scanner.py +0 -0
  93. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_pypi_project_surface.py +0 -0
  94. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_python_conformance.py +0 -0
  95. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_queries.py +0 -0
  96. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_readme_quickstart.py +0 -0
  97. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_redrive_replay.py +0 -0
  98. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_release_metadata.py +0 -0
  99. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay.py +0 -0
  100. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay_conformance.py +0 -0
  101. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay_regression_corpus.py +0 -0
  102. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay_verify.py +0 -0
  103. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_retry_policy.py +0 -0
  104. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_run_cancellation_scope_replay.py +0 -0
  105. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_runtime_external_payload_transport.py +0 -0
  106. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_schedules.py +0 -0
  107. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_scoped_run_cancellation_context.py +0 -0
  108. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_serializer.py +0 -0
  109. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_signals.py +0 -0
  110. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_sleep.py +0 -0
  111. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_standalone_activity_client.py +0 -0
  112. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_storage_admission.py +0 -0
  113. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_testing_harness.py +0 -0
  114. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_update_signal_condition_replay.py +0 -0
  115. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_updates.py +0 -0
  116. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_wait_condition.py +0 -0
  117. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_worker.py +0 -0
  118. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_worker_session.py +0 -0
  119. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_workflow_result_timeout.py +0 -0
  120. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_workflow_streams.py +0 -0
  121. {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.4.1
3
+ Version: 2.4.3
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.4.1"
7
+ version = "2.4.3"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.4.1"
75
- registry-version = "2.4.1"
74
+ product-train = "2.4.3"
75
+ registry-version = "2.4.3"
76
76
  supported-server-versions = "2.5.0"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -95,6 +95,7 @@ from .errors import (
95
95
  ServerError,
96
96
  SignalFailed,
97
97
  Unauthorized,
98
+ UpdateFailed,
98
99
  UpdateRejected,
99
100
  UpdateValidationFailed,
100
101
  WorkflowAlreadyStarted,
@@ -385,6 +386,7 @@ __all__ = [
385
386
  "SignalFailed",
386
387
  "TransportRetryPolicy",
387
388
  "Unauthorized",
389
+ "UpdateFailed",
388
390
  "UpdateRejected",
389
391
  "UpdateValidationFailed",
390
392
  "WorkflowAlreadyStarted",
@@ -4349,7 +4349,9 @@ class Client:
4349
4349
  :class:`~durable_workflow.errors.UpdateRejected` when the workflow's
4350
4350
  validator rejects the update, or
4351
4351
  :class:`~durable_workflow.errors.UpdateValidationFailed` when the
4352
- declared validation boundary cannot be enforced.
4352
+ declared validation boundary cannot be enforced. An accepted update
4353
+ whose handler fails raises :class:`~durable_workflow.errors.UpdateFailed`
4354
+ with the failure message, Server response and durable identities.
4353
4355
  """
4354
4356
  await self._require_update_wait_stage(wait_for or "accepted")
4355
4357
  body: dict[str, Any] = {}
@@ -5115,11 +5117,23 @@ class Client:
5115
5117
 
5116
5118
  Worker-plane endpoint, called by :class:`~durable_workflow.Worker`.
5117
5119
  ``commands`` is the list of serialized commands the workflow yielded
5118
- for this task.
5120
+ for this task. An empty list acknowledges waiting for already scheduled
5121
+ history without reporting a workflow failure or scheduling another operation.
5119
5122
  """
5120
5123
  if (message_stream_cursors or message_stream_waits) and not _worker_protocol_supports_message_streams():
5121
5124
  raise ValueError("message stream completion metadata requires worker protocol 1.15 or newer")
5122
5125
 
5126
+ if not commands:
5127
+ if message_stream_cursors or message_stream_waits:
5128
+ raise ValueError("Message stream completion metadata requires nonempty commands.")
5129
+ return await self.fail_workflow_task(
5130
+ task_id=task_id,
5131
+ lease_owner=lease_owner,
5132
+ workflow_task_attempt=workflow_task_attempt,
5133
+ message="Workflow task waiting for scheduled history.",
5134
+ failure_type="WorkflowTaskWaitingForHistory",
5135
+ )
5136
+
5123
5137
  body: dict[str, Any] = {
5124
5138
  "lease_owner": lease_owner,
5125
5139
  "workflow_task_attempt": workflow_task_attempt,
@@ -453,6 +453,29 @@ class NonDeterministicReplayError(DurableWorkflowError):
453
453
  self.recorded_event_types = list(recorded_event_types)
454
454
 
455
455
 
456
+ class UpdateFailed(InvalidArgument):
457
+ """An accepted workflow update failed during handler execution.
458
+
459
+ ``body`` preserves the Server response, including its durable failure
460
+ identity. This inherits from :class:`InvalidArgument` to preserve catches
461
+ written for earlier SDK versions that mapped all HTTP 422 responses there.
462
+ """
463
+
464
+ def __init__(self, message: str, *, status: int, body: dict[str, Any]) -> None:
465
+ super().__init__(message)
466
+ self.status = status
467
+ self.body = body
468
+ self.workflow_id = self._identity(body, "workflow_id")
469
+ self.run_id = self._identity(body, "run_id")
470
+ self.update_id = self._identity(body, "update_id")
471
+ self.failure_id = self._identity(body, "failure_id")
472
+
473
+ @staticmethod
474
+ def _identity(body: dict[str, Any], name: str) -> str | None:
475
+ value = body.get(name)
476
+ return value if isinstance(value, str) and value.strip() else None
477
+
478
+
456
479
  class UpdateRejected(DurableWorkflowError):
457
480
  """A workflow update was rejected by the workflow's validator."""
458
481
 
@@ -859,6 +882,13 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
859
882
  raise signal_failed("signal argument validation failed")
860
883
  if reason == "invalid_query_arguments":
861
884
  raise query_failed("query argument validation failed")
885
+ if isinstance(body, dict) and body.get("update_status") == "failed":
886
+ failure_message = body.get("failure_message")
887
+ if not isinstance(failure_message, str) or not failure_message.strip():
888
+ failure_message = message
889
+ if not isinstance(failure_message, str) or not failure_message.strip():
890
+ failure_message = "workflow update failed"
891
+ raise UpdateFailed(failure_message, status=status, body=body)
862
892
  errors = None
863
893
  if isinstance(body, dict):
864
894
  errors = body.get("errors") or body.get("validation_errors")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.4.1
3
+ Version: 2.4.3
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -30,6 +30,7 @@ from durable_workflow.errors import (
30
30
  ServerError,
31
31
  SignalFailed,
32
32
  Unauthorized,
33
+ UpdateFailed,
33
34
  UpdateRejected,
34
35
  WorkflowAlreadyStarted,
35
36
  WorkflowNotFound,
@@ -2903,6 +2904,25 @@ class TestFailWorkflowTask:
2903
2904
  assert mock.call_args.args[:2] == (fixture["request"]["method"], f"/api{fixture['request']['path']}")
2904
2905
  assert mock.call_args.kwargs["json"] == fixture["request"]["body"]
2905
2906
 
2907
+ @pytest.mark.asyncio
2908
+ @pytest.mark.parametrize("metadata", [
2909
+ {"message_stream_cursors": [{"stream_name": "input", "sequence": 7}]},
2910
+ {"message_stream_waits": [{"stream_name": "input"}]},
2911
+ ])
2912
+ async def test_empty_completion_does_not_discard_message_stream_metadata(
2913
+ self, client: Client, metadata: dict
2914
+ ) -> None:
2915
+ with (
2916
+ patch.object(client._http, "request", new_callable=AsyncMock,
2917
+ return_value=_mock_response(200, {"completed": True})) as mock,
2918
+ pytest.raises(ValueError, match="nonempty commands"),
2919
+ ):
2920
+ await client.complete_workflow_task(
2921
+ task_id="task", lease_owner="owner", workflow_task_attempt=3,
2922
+ commands=[], **metadata,
2923
+ )
2924
+ mock.assert_not_awaited()
2925
+
2906
2926
  @pytest.mark.asyncio
2907
2927
  async def test_fail_workflow_task_matches_polyglot_fixture(self, client: Client) -> None:
2908
2928
  fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "workflow-task-fail-parity.json"
@@ -3071,6 +3091,34 @@ class TestHeartbeatActivityTask:
3071
3091
 
3072
3092
 
3073
3093
  class TestUpdateWorkflow:
3094
+ @pytest.mark.asyncio
3095
+ async def test_failed_update_retries_preserve_failure_identity(self, client: Client) -> None:
3096
+ body = {
3097
+ "update_status": "failed", "accepted": True,
3098
+ "failure_message": "inventory unavailable", "workflow_id": "wf-1",
3099
+ "run_id": "run-1", "update_id": "update-1", "failure_id": "failure-1",
3100
+ }
3101
+ with patch.object(
3102
+ client._http, "request", new_callable=AsyncMock, return_value=_mock_response(422, body),
3103
+ ) as request:
3104
+ failures = []
3105
+ for _ in range(2):
3106
+ with pytest.raises(UpdateFailed) as exc_info:
3107
+ await client.update_workflow(
3108
+ "wf-1", "reserve", wait_for="completed", request_id="request-1",
3109
+ )
3110
+ failures.append(exc_info.value)
3111
+ assert request.await_count == 2
3112
+ assert all(call.kwargs["json"]["request_id"] == "request-1"
3113
+ for call in request.await_args_list)
3114
+ for error in failures:
3115
+ assert str(error) == "inventory unavailable"
3116
+ assert error.status == 422
3117
+ assert error.body == body
3118
+ assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
3119
+ "wf-1", "run-1", "update-1", "failure-1",
3120
+ )
3121
+
3074
3122
  @pytest.mark.asyncio
3075
3123
  async def test_unsupported_wait_stage_raises_typed_error_before_update(self) -> None:
3076
3124
  client = Client("http://localhost:8080", token="test-token")
@@ -13,6 +13,7 @@ from durable_workflow.errors import (
13
13
  ServerError,
14
14
  SignalFailed,
15
15
  Unauthorized,
16
+ UpdateFailed,
16
17
  UpdateRejected,
17
18
  UpdateValidationFailed,
18
19
  WorkflowAlreadyStarted,
@@ -102,6 +103,56 @@ class TestRaiseForStatus:
102
103
  with pytest.raises(InvalidArgument) as exc_info:
103
104
  _raise_for_status(422, {"message": "bad", "errors": {"f": ["req"]}})
104
105
  assert exc_info.value.errors == {"f": ["req"]}
106
+ assert type(exc_info.value) is InvalidArgument
107
+
108
+ def test_422_accepted_update_failure_preserves_diagnostics_and_legacy_catch(self) -> None:
109
+ body = {
110
+ "update_status": "failed", "accepted": True,
111
+ "failure_message": "inventory unavailable", "message": "generic failure",
112
+ "workflow_id": "wf-1", "run_id": "run-1", "update_id": "update-1",
113
+ "failure_id": "failure-1",
114
+ }
115
+ with pytest.raises(InvalidArgument) as exc_info:
116
+ _raise_for_status(422, body)
117
+ error = exc_info.value
118
+ assert isinstance(error, UpdateFailed)
119
+ assert str(error) == "inventory unavailable"
120
+ assert error.status == 422
121
+ assert error.body is body
122
+ assert error.errors is None
123
+ assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
124
+ "wf-1", "run-1", "update-1", "failure-1",
125
+ )
126
+
127
+ @pytest.mark.parametrize("failure_message", [None, "", " ", 42])
128
+ @pytest.mark.parametrize("message", ["handler failed", None, "", " ", 42])
129
+ def test_update_failure_message_fallback(self, failure_message: object, message: object) -> None:
130
+ with pytest.raises(UpdateFailed) as exc_info:
131
+ _raise_for_status(422, {
132
+ "update_status": "failed", "failure_message": failure_message, "message": message,
133
+ })
134
+ assert str(exc_info.value) == (
135
+ message if message == "handler failed" else "workflow update failed"
136
+ )
137
+
138
+ def test_update_failure_missing_or_malformed_identities_remain_absent(self) -> None:
139
+ with pytest.raises(UpdateFailed) as exc_info:
140
+ _raise_for_status(422, {
141
+ "update_status": "failed", "workflow_id": 42, "run_id": " ", "update_id": None,
142
+ })
143
+ error = exc_info.value
144
+ assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
145
+ None, None, None, None,
146
+ )
147
+
148
+ def test_rejected_update_arguments_are_not_handler_failure(self) -> None:
149
+ with pytest.raises(InvalidArgument) as exc_info:
150
+ _raise_for_status(422, {
151
+ "update_status": "rejected", "message": "invalid arguments",
152
+ "validation_errors": {"quantity": ["must be positive"]},
153
+ })
154
+ assert type(exc_info.value) is InvalidArgument
155
+ assert exc_info.value.errors == {"quantity": ["must be positive"]}
105
156
 
106
157
  def test_422_update_validator_rejected_is_typed(self) -> None:
107
158
  with pytest.raises(UpdateRejected) as exc_info:
@@ -109,6 +160,7 @@ class TestRaiseForStatus:
109
160
  422,
110
161
  {
111
162
  "reason": "update_validator_rejected",
163
+ "update_status": "failed",
112
164
  "message": "approval required",
113
165
  "validation_errors": {"approved": ["must be true"]},
114
166
  },
@@ -132,6 +184,7 @@ class TestRaiseForStatus:
132
184
  status,
133
185
  {
134
186
  "reason": reason,
187
+ "update_status": "failed",
135
188
  "message": "validation could not complete",
136
189
  "retryable": retryable,
137
190
  },
@@ -2,10 +2,12 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from typing import Any
5
+ from unittest.mock import AsyncMock, patch
5
6
 
7
+ import httpx
6
8
  import pytest
7
9
 
8
- from durable_workflow import serializer, workflow
10
+ from durable_workflow import Client, Worker, serializer, workflow
9
11
  from durable_workflow.errors import NonDeterministicReplayError
10
12
  from durable_workflow.workflow import CompleteWorkflow, WorkflowContext, query_state, replay
11
13
 
@@ -78,6 +80,43 @@ def test_signal_replay_does_not_reschedule_pending_operation(kind: str) -> None:
78
80
  )
79
81
 
80
82
 
83
+ @pytest.mark.asyncio
84
+ @pytest.mark.parametrize("kind", OPENINGS)
85
+ async def test_worker_acknowledges_pending_signal_replay_with_original_lease(kind: str) -> None:
86
+ history = initial_history() + [OPENINGS[kind], {
87
+ "event_type": "SignalReceived", "payload": {
88
+ "signal_name": "increment", "arguments": serializer.envelope([7]),
89
+ },
90
+ }]
91
+ requests = []
92
+
93
+ async def server(method, path, **kwargs): # type: ignore[no-untyped-def]
94
+ body = kwargs["json"]
95
+ requests.append((method, path, body, kwargs["headers"]["Authorization"]))
96
+ if path.endswith("/complete") and not body["commands"]:
97
+ return httpx.Response(422, json={"message": "The commands field is required."},
98
+ request=httpx.Request(method, "http://test" + path))
99
+ assert path.endswith("/fail")
100
+ return httpx.Response(200, json={"outcome": "waiting_for_history", "recorded": True},
101
+ request=httpx.Request(method, "http://test" + path))
102
+
103
+ async with Client("http://test", worker_token="worker-token") as client:
104
+ worker = Worker(client, task_queue="queue", worker_id="original-owner", workflows=[PendingOperation])
105
+ with patch.object(client._http, "request", new=AsyncMock(side_effect=server)):
106
+ commands = await worker._run_workflow_task({
107
+ "task_id": "original-task", "workflow_task_attempt": 4, "run_id": "original-run",
108
+ "workflow_type": "tests.replay.pending-operation", "history_events": history,
109
+ "payload_codec": "avro", "arguments": serializer.envelope([kind]),
110
+ })
111
+
112
+ assert commands == []
113
+ assert requests == [("POST", "/api/worker/workflow-tasks/original-task/fail", {
114
+ "lease_owner": "original-owner", "workflow_task_attempt": 4,
115
+ "failure": {"message": "Workflow task waiting for scheduled history.",
116
+ "type": "WorkflowTaskWaitingForHistory"},
117
+ }, "Bearer worker-token")]
118
+
119
+
81
120
  @pytest.mark.parametrize("kind", OPENINGS)
82
121
  def test_new_operation_is_still_scheduled(kind: str) -> None:
83
122
  outcome = replay(PendingOperation, initial_history(), [kind])
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch
9
9
  import httpx
10
10
  import pytest
11
11
 
12
- from durable_workflow import serializer
12
+ from durable_workflow import InvalidArgument, UpdateFailed, serializer
13
13
  from durable_workflow.client import WorkflowHandle
14
14
  from durable_workflow.external_storage import ExternalPayloadCache, LocalFilesystemExternalStorage
15
15
  from durable_workflow.sync import Client, SyncStandaloneActivityHandle, SyncWorkflowHandle
@@ -581,6 +581,34 @@ class TestSyncClientMaintenance:
581
581
 
582
582
 
583
583
  class TestSyncClientUpdate:
584
+ def test_failed_update_preserves_diagnostics(self) -> None:
585
+ body = {
586
+ "update_status": "failed", "failure_message": "inventory unavailable",
587
+ "workflow_id": "wf-1", "run_id": "run-1", "update_id": "update-1",
588
+ "failure_id": "failure-1",
589
+ }
590
+ with Client("http://localhost:8080") as client:
591
+ client._async._cluster_info = {
592
+ "control_plane": {"request_contract": {"operations": {"update": {
593
+ "fields": {"wait_for": {"canonical_values": ["accepted", "completed"]}},
594
+ }}}},
595
+ }
596
+ with (
597
+ patch.object(client._async._http, "request", new_callable=AsyncMock,
598
+ return_value=_mock_response(422, body)) as request,
599
+ pytest.raises(InvalidArgument) as exc_info,
600
+ ):
601
+ client.update_workflow("wf-1", "reserve", request_id="request-1")
602
+ error = exc_info.value
603
+ assert isinstance(error, UpdateFailed)
604
+ assert str(error) == "inventory unavailable"
605
+ assert error.status == 422
606
+ assert error.body == body
607
+ assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
608
+ "wf-1", "run-1", "update-1", "failure-1",
609
+ )
610
+ assert request.call_args.kwargs["json"]["request_id"] == "request-1"
611
+
584
612
  def test_update(self) -> None:
585
613
  client = Client("http://localhost:8080")
586
614
  client._async._cluster_info = {