durable-workflow 2.4.2__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.2/src/durable_workflow.egg-info → durable_workflow-2.4.3}/PKG-INFO +1 -1
  2. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/pyproject.toml +3 -3
  3. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/client.py +3 -1
  5. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/errors.py +30 -0
  6. {durable_workflow-2.4.2 → durable_workflow-2.4.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_client.py +29 -0
  8. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_errors.py +53 -0
  9. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_sync.py +29 -1
  10. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/LICENSE +0 -0
  11. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/README.md +0 -0
  12. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/setup.cfg +0 -0
  13. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/_activity_process.py +0 -0
  14. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/_avro.py +0 -0
  15. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/_cancellation_scope.py +0 -0
  16. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  17. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  18. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/_prepared_local_activity.py +0 -0
  19. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/activity.py +0 -0
  20. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/auth_composition.py +0 -0
  21. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/cancellation.py +0 -0
  22. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/external_storage.py +0 -0
  23. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/external_task_input.py +0 -0
  24. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/external_task_result.py +0 -0
  25. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/history_bundle_verify.py +0 -0
  26. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/interceptors.py +0 -0
  27. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/invocable.py +0 -0
  28. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/metrics.py +0 -0
  29. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/nexus.py +0 -0
  30. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/py.typed +0 -0
  31. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/python_conformance.py +0 -0
  32. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/replay_conformance.py +0 -0
  33. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/replay_verify.py +0 -0
  34. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/retry_policy.py +0 -0
  35. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/serializer.py +0 -0
  36. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/sync.py +0 -0
  37. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/testing.py +0 -0
  38. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/worker.py +0 -0
  39. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/worker_session.py +0 -0
  40. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/workflow.py +0 -0
  41. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  42. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  43. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  44. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  45. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/requires.txt +0 -0
  46. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
  47. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_activity_cancellation_policies.py +0 -0
  48. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_activity_context.py +0 -0
  49. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_activity_process.py +0 -0
  50. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_auth_composition.py +0 -0
  51. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_avro_value_benchmark.py +0 -0
  52. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_avro_value_protocol.py +0 -0
  53. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cancellation_context.py +0 -0
  54. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cancellation_remaining_time.py +0 -0
  55. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cancellation_scope_admission.py +0 -0
  56. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cancellation_scope_authoring.py +0 -0
  57. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cancellation_scope_boundary.py +0 -0
  58. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cancellation_scope_opening.py +0 -0
  59. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_child_workflow_policies.py +0 -0
  60. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_ci_checkout.py +0 -0
  61. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_ci_integration_endpoint.py +0 -0
  62. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_client_timeouts.py +0 -0
  63. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_codec_regression_corpus.py +0 -0
  64. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  65. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_groups.py +0 -0
  66. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_history.py +0 -0
  67. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_replay.py +0 -0
  68. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_control_plane_parity_fixtures.py +0 -0
  69. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation.py +0 -0
  70. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_client.py +0 -0
  71. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_worker.py +0 -0
  72. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_cooperative_remote_worker.py +0 -0
  73. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_docstring_cross_references.py +0 -0
  74. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_durable_selection.py +0 -0
  75. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_external_storage.py +0 -0
  76. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_external_task_input.py +0 -0
  77. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_external_task_result.py +0 -0
  78. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_golden_history_replay.py +0 -0
  79. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_history_bundle_verify.py +0 -0
  80. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_history_event_contract.py +0 -0
  81. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_invocable.py +0 -0
  82. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_message_streams.py +0 -0
  83. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_metrics.py +0 -0
  84. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_order_processing_example.py +0 -0
  85. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_parallel_completion_order_replay.py +0 -0
  86. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_parallel_saga_authoring.py +0 -0
  87. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_pending_operation_replay.py +0 -0
  88. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_client.py +0 -0
  89. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_groups.py +0 -0
  90. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_policies.py +0 -0
  91. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_worker.py +0 -0
  92. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_public_boundary_scanner.py +0 -0
  93. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_pypi_project_surface.py +0 -0
  94. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_python_conformance.py +0 -0
  95. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_queries.py +0 -0
  96. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_readme_quickstart.py +0 -0
  97. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_redrive_replay.py +0 -0
  98. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_release_metadata.py +0 -0
  99. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_replay.py +0 -0
  100. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_replay_conformance.py +0 -0
  101. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_replay_regression_corpus.py +0 -0
  102. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_replay_verify.py +0 -0
  103. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_retry_policy.py +0 -0
  104. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_run_cancellation_scope_replay.py +0 -0
  105. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_runtime_external_payload_transport.py +0 -0
  106. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_schedules.py +0 -0
  107. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_scoped_run_cancellation_context.py +0 -0
  108. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_serializer.py +0 -0
  109. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_signals.py +0 -0
  110. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_sleep.py +0 -0
  111. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_standalone_activity_client.py +0 -0
  112. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_storage_admission.py +0 -0
  113. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_testing_harness.py +0 -0
  114. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_update_signal_condition_replay.py +0 -0
  115. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_updates.py +0 -0
  116. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_wait_condition.py +0 -0
  117. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_worker.py +0 -0
  118. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_worker_session.py +0 -0
  119. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_workflow_result_timeout.py +0 -0
  120. {durable_workflow-2.4.2 → durable_workflow-2.4.3}/tests/test_workflow_streams.py +0 -0
  121. {durable_workflow-2.4.2 → 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.2
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.2"
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.2"
75
- registry-version = "2.4.2"
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] = {}
@@ -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.2
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,
@@ -3090,6 +3091,34 @@ class TestHeartbeatActivityTask:
3090
3091
 
3091
3092
 
3092
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
+
3093
3122
  @pytest.mark.asyncio
3094
3123
  async def test_unsupported_wait_stage_raises_typed_error_before_update(self) -> None:
3095
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
  },
@@ -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 = {