durable-workflow 0.4.45__tar.gz → 0.4.47__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 (62) hide show
  1. {durable_workflow-0.4.45/src/durable_workflow.egg-info → durable_workflow-0.4.47}/PKG-INFO +1 -1
  2. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/pyproject.toml +1 -1
  3. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/worker.py +87 -12
  4. {durable_workflow-0.4.45 → durable_workflow-0.4.47/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  5. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_worker.py +179 -18
  6. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/LICENSE +0 -0
  7. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/README.md +0 -0
  8. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/setup.cfg +0 -0
  9. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/__init__.py +0 -0
  10. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/_avro.py +0 -0
  11. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/activity.py +0 -0
  12. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/auth_composition.py +0 -0
  13. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/client.py +0 -0
  14. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/errors.py +0 -0
  15. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_storage.py +0 -0
  16. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_task_input.py +0 -0
  17. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/external_task_result.py +0 -0
  18. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/history_bundle_verify.py +0 -0
  19. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/interceptors.py +0 -0
  20. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/invocable.py +0 -0
  21. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/metrics.py +0 -0
  22. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/py.typed +0 -0
  23. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/replay_verify.py +0 -0
  24. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/retry_policy.py +0 -0
  25. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/serializer.py +0 -0
  26. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/sync.py +0 -0
  27. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/testing.py +0 -0
  28. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow/workflow.py +0 -0
  29. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  30. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  31. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  32. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/requires.txt +0 -0
  33. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/src/durable_workflow.egg-info/top_level.txt +0 -0
  34. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_activity_context.py +0 -0
  35. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_auth_composition.py +0 -0
  36. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_client.py +0 -0
  37. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_control_plane_parity_fixtures.py +0 -0
  38. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_errors.py +0 -0
  39. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_external_storage.py +0 -0
  40. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_external_task_input.py +0 -0
  41. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_external_task_result.py +0 -0
  42. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_golden_history_replay.py +0 -0
  43. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_history_bundle_verify.py +0 -0
  44. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_history_event_contract.py +0 -0
  45. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_invocable.py +0 -0
  46. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_metrics.py +0 -0
  47. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_order_processing_example.py +0 -0
  48. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_public_boundary_scanner.py +0 -0
  49. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_queries.py +0 -0
  50. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_readme_quickstart.py +0 -0
  51. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_replay.py +0 -0
  52. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_replay_verify.py +0 -0
  53. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_retry_policy.py +0 -0
  54. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_schedules.py +0 -0
  55. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_serializer.py +0 -0
  56. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_signals.py +0 -0
  57. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_sleep.py +0 -0
  58. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_standalone_activity_client.py +0 -0
  59. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_sync.py +0 -0
  60. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_testing_harness.py +0 -0
  61. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_updates.py +0 -0
  62. {durable_workflow-0.4.45 → durable_workflow-0.4.47}/tests/test_wait_condition.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.45
3
+ Version: 0.4.47
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
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 = "0.4.45"
7
+ version = "0.4.47"
8
8
  description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -45,7 +45,15 @@ from .client import (
45
45
  Client,
46
46
  WorkflowExecution,
47
47
  )
48
- from .errors import ActivityCancelled, AvroNotInstalledError, NonRetryableError, QueryFailed, ServerError
48
+ from .errors import (
49
+ ActivityCancelled,
50
+ AvroNotInstalledError,
51
+ DurableWorkflowError,
52
+ InvalidArgument,
53
+ NonRetryableError,
54
+ QueryFailed,
55
+ ServerError,
56
+ )
49
57
  from .external_storage import ExternalPayloadCache, ExternalStorageDriver
50
58
  from .interceptors import (
51
59
  ActivityInterceptorContext,
@@ -74,6 +82,17 @@ _QUERY_TASK_FINAL_REJECTION_REASONS = {
74
82
  "query_task_not_leased",
75
83
  "query_task_timed_out",
76
84
  }
85
+ _WORKFLOW_TASK_COMPLETION_AMBIGUOUS_REJECTION_REASONS = {
86
+ "lease_expired",
87
+ "lease_owner_mismatch",
88
+ "run_already_closed",
89
+ "run_closed",
90
+ "task_not_found",
91
+ "task_not_leased",
92
+ "workflow_task_attempt_mismatch",
93
+ }
94
+ _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS = 3
95
+ _WORKFLOW_TASK_COMPLETION_RETRY_DELAYS = (0.05, 0.2)
77
96
  _WORKER_WORKFLOW_FINGERPRINTS: dict[tuple[str, str], str] = {}
78
97
 
79
98
 
@@ -111,6 +130,26 @@ def _is_final_query_task_rejection(error: BaseException) -> bool:
111
130
  )
112
131
 
113
132
 
133
+ def _should_fail_workflow_task_after_completion_error(error: BaseException) -> bool:
134
+ if isinstance(error, InvalidArgument):
135
+ return True
136
+
137
+ if isinstance(error, ServerError):
138
+ if error.status >= 500 or error.status == 429:
139
+ return False
140
+
141
+ return error.reason() not in _WORKFLOW_TASK_COMPLETION_AMBIGUOUS_REJECTION_REASONS
142
+
143
+ return isinstance(error, DurableWorkflowError)
144
+
145
+
146
+ def _should_retry_workflow_task_completion_error(error: BaseException) -> bool:
147
+ if isinstance(error, ServerError):
148
+ return error.status >= 500 or error.status == 429
149
+
150
+ return not isinstance(error, DurableWorkflowError)
151
+
152
+
114
153
  def _signal_arguments_envelope_from_export(
115
154
  signal: Mapping[str, Any],
116
155
  *,
@@ -767,16 +806,16 @@ class Worker:
767
806
  command["type"],
768
807
  )
769
808
  try:
770
- await self.client.complete_workflow_task(
809
+ await self._complete_workflow_task_with_retry(
771
810
  task_id=task_id,
772
- lease_owner=self.worker_id,
773
- workflow_task_attempt=attempt,
811
+ attempt=attempt,
774
812
  commands=[command],
775
813
  )
776
814
  except Exception as e:
777
815
  log.warning("failed to complete workflow update task %s: %s", task_id, e)
778
- await self._fail_workflow_task_after_completion_error(task_id, attempt, e)
779
- return None
816
+ if _should_fail_workflow_task_after_completion_error(e):
817
+ await self._report_workflow_task_after_completion_error(task_id, attempt, e)
818
+ return None
780
819
  return [command]
781
820
 
782
821
  try:
@@ -841,19 +880,55 @@ class Worker:
841
880
  [c["type"] for c in commands],
842
881
  )
843
882
  try:
844
- await self.client.complete_workflow_task(
883
+ await self._complete_workflow_task_with_retry(
845
884
  task_id=task_id,
846
- lease_owner=self.worker_id,
847
- workflow_task_attempt=attempt,
885
+ attempt=attempt,
848
886
  commands=commands,
849
887
  )
850
888
  except Exception as e:
851
889
  log.warning("failed to complete workflow task %s: %s", task_id, e)
852
- await self._fail_workflow_task_after_completion_error(task_id, attempt, e)
853
- return None
890
+ if _should_fail_workflow_task_after_completion_error(e):
891
+ await self._report_workflow_task_after_completion_error(task_id, attempt, e)
892
+ return None
854
893
  return commands
855
894
 
856
- async def _fail_workflow_task_after_completion_error(
895
+ async def _complete_workflow_task_with_retry(
896
+ self,
897
+ *,
898
+ task_id: str,
899
+ attempt: int,
900
+ commands: list[dict[str, Any]],
901
+ ) -> None:
902
+ for completion_attempt in range(1, _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS + 1):
903
+ try:
904
+ await self.client.complete_workflow_task(
905
+ task_id=task_id,
906
+ lease_owner=self.worker_id,
907
+ workflow_task_attempt=attempt,
908
+ commands=commands,
909
+ )
910
+ return
911
+ except Exception as error:
912
+ if (
913
+ completion_attempt >= _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS
914
+ or not _should_retry_workflow_task_completion_error(error)
915
+ ):
916
+ raise
917
+
918
+ delay_index = min(
919
+ completion_attempt - 1,
920
+ len(_WORKFLOW_TASK_COMPLETION_RETRY_DELAYS) - 1,
921
+ )
922
+ log.warning(
923
+ "retrying workflow task %s completion after attempt %d/%d failed: %s",
924
+ task_id,
925
+ completion_attempt,
926
+ _WORKFLOW_TASK_COMPLETION_MAX_ATTEMPTS,
927
+ error,
928
+ )
929
+ await asyncio.sleep(_WORKFLOW_TASK_COMPLETION_RETRY_DELAYS[delay_index])
930
+
931
+ async def _report_workflow_task_after_completion_error(
857
932
  self,
858
933
  task_id: str,
859
934
  attempt: int,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.45
3
+ Version: 0.4.47
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ from durable_workflow.client import (
22
22
  Client,
23
23
  WorkflowExecution,
24
24
  )
25
- from durable_workflow.errors import ServerError
25
+ from durable_workflow.errors import InvalidArgument, ServerError, Unauthorized, WorkflowNotFound
26
26
  from durable_workflow.interceptors import (
27
27
  ActivityHandler,
28
28
  ActivityInterceptorContext,
@@ -32,7 +32,7 @@ from durable_workflow.interceptors import (
32
32
  WorkflowTaskHandler,
33
33
  WorkflowTaskInterceptorContext,
34
34
  )
35
- from durable_workflow.worker import Worker
35
+ from durable_workflow.worker import Worker, _should_fail_workflow_task_after_completion_error
36
36
 
37
37
 
38
38
  @workflow.defn(name="test-wf")
@@ -185,6 +185,28 @@ def compatible_cluster_info(**overrides: object) -> dict[str, object]:
185
185
  return info
186
186
 
187
187
 
188
+ class TestWorkflowTaskCompletionErrorClassification:
189
+ @pytest.mark.parametrize(
190
+ ("error", "should_fail"),
191
+ [
192
+ (TimeoutError("completion timed out"), False),
193
+ (RuntimeError("connection reset"), False),
194
+ (ServerError(409, {"reason": "lease_expired"}), False),
195
+ (ServerError(409, {"reason": "workflow_task_attempt_mismatch"}), False),
196
+ (ServerError(429, {"reason": "rate_limited"}), False),
197
+ (ServerError(503, {"reason": "server_busy"}), False),
198
+ (ServerError(409, {"reason": "invalid_commands"}), True),
199
+ (InvalidArgument("invalid command payload"), True),
200
+ (Unauthorized("missing bearer token"), True),
201
+ (WorkflowNotFound("wf-missing"), True),
202
+ ],
203
+ )
204
+ def test_classifies_definite_and_ambiguous_completion_errors(
205
+ self, error: BaseException, should_fail: bool
206
+ ) -> None:
207
+ assert _should_fail_workflow_task_after_completion_error(error) is should_fail
208
+
209
+
188
210
  class TestWorkerRegistration:
189
211
  @pytest.mark.asyncio
190
212
  async def test_register(self, mock_client: AsyncMock) -> None:
@@ -502,13 +524,97 @@ class TestWorkflowTaskExecution:
502
524
  assert serializer.decode(commands[0]["arguments"]["blob"], codec="json") == ["hello"]
503
525
 
504
526
  @pytest.mark.asyncio
505
- async def test_workflow_task_completion_error_fails_task_for_fast_redispatch(
527
+ async def test_workflow_task_ambiguous_completion_error_preserves_commands(
528
+ self, mock_client: AsyncMock
529
+ ) -> None:
530
+ mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "task_not_leased"})
531
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
532
+ task = {
533
+ "task_id": "t-complete-not-leased",
534
+ "workflow_type": "test-wf",
535
+ "workflow_task_attempt": 2,
536
+ "history_events": [],
537
+ "arguments": '["hello"]',
538
+ "payload_codec": "json",
539
+ }
540
+
541
+ result = await worker._run_workflow_task(task)
542
+
543
+ assert result is not None
544
+ assert result[0]["type"] == "schedule_activity"
545
+ mock_client.complete_workflow_task.assert_awaited_once()
546
+ mock_client.fail_workflow_task.assert_not_called()
547
+
548
+ @pytest.mark.asyncio
549
+ async def test_workflow_task_retries_transient_completion_error(
550
+ self, mock_client: AsyncMock
551
+ ) -> None:
552
+ mock_client.complete_workflow_task.side_effect = [
553
+ TimeoutError("completion timed out"),
554
+ {"outcome": "completed"},
555
+ ]
556
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
557
+ task = {
558
+ "task_id": "t-complete-retry",
559
+ "workflow_type": "test-wf",
560
+ "workflow_task_attempt": 2,
561
+ "history_events": [],
562
+ "arguments": '["hello"]',
563
+ "payload_codec": "json",
564
+ }
565
+
566
+ result = await worker._run_workflow_task(task)
567
+
568
+ assert result is not None
569
+ assert result[0]["type"] == "schedule_activity"
570
+ assert mock_client.complete_workflow_task.await_count == 2
571
+ mock_client.fail_workflow_task.assert_not_called()
572
+
573
+ @pytest.mark.asyncio
574
+ async def test_workflow_task_definite_completion_rejection_fails_task(
506
575
  self, mock_client: AsyncMock
507
576
  ) -> None:
508
- mock_client.complete_workflow_task.side_effect = TimeoutError("completion timed out")
577
+ mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "invalid_commands"})
509
578
  worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
510
579
  task = {
511
- "task_id": "t-complete-timeout",
580
+ "task_id": "t-complete-invalid",
581
+ "workflow_type": "test-wf",
582
+ "workflow_task_attempt": 2,
583
+ "history_events": [],
584
+ "arguments": '["hello"]',
585
+ "payload_codec": "json",
586
+ }
587
+
588
+ result = await worker._run_workflow_task(task)
589
+
590
+ assert result is None
591
+ mock_client.fail_workflow_task.assert_awaited_once()
592
+ call_kwargs = mock_client.fail_workflow_task.await_args.kwargs
593
+ assert call_kwargs["task_id"] == "t-complete-invalid"
594
+ assert call_kwargs["workflow_task_attempt"] == 2
595
+ assert call_kwargs["lease_owner"] == worker.worker_id
596
+ assert call_kwargs["failure_type"] == "ServerError"
597
+ assert "invalid_commands" in call_kwargs["message"]
598
+
599
+ @pytest.mark.parametrize(
600
+ ("completion_error", "failure_type", "message_fragment"),
601
+ [
602
+ (Unauthorized("missing bearer token"), "Unauthorized", "missing bearer token"),
603
+ (WorkflowNotFound("wf-typed-missing"), "WorkflowNotFound", "wf-typed-missing"),
604
+ ],
605
+ )
606
+ @pytest.mark.asyncio
607
+ async def test_workflow_task_typed_completion_rejection_fails_task(
608
+ self,
609
+ mock_client: AsyncMock,
610
+ completion_error: Exception,
611
+ failure_type: str,
612
+ message_fragment: str,
613
+ ) -> None:
614
+ mock_client.complete_workflow_task.side_effect = completion_error
615
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
616
+ task = {
617
+ "task_id": "t-complete-typed-rejection",
512
618
  "workflow_type": "test-wf",
513
619
  "workflow_task_attempt": 2,
514
620
  "history_events": [],
@@ -522,11 +628,33 @@ class TestWorkflowTaskExecution:
522
628
  mock_client.complete_workflow_task.assert_awaited_once()
523
629
  mock_client.fail_workflow_task.assert_awaited_once()
524
630
  call_kwargs = mock_client.fail_workflow_task.await_args.kwargs
525
- assert call_kwargs["task_id"] == "t-complete-timeout"
631
+ assert call_kwargs["task_id"] == "t-complete-typed-rejection"
526
632
  assert call_kwargs["workflow_task_attempt"] == 2
527
633
  assert call_kwargs["lease_owner"] == worker.worker_id
528
- assert call_kwargs["failure_type"] == "TimeoutError"
529
- assert "completion timed out" in call_kwargs["message"]
634
+ assert call_kwargs["failure_type"] == failure_type
635
+ assert message_fragment in call_kwargs["message"]
636
+
637
+ @pytest.mark.asyncio
638
+ async def test_workflow_task_definite_completion_rejection_stays_failed_when_report_fails(
639
+ self, mock_client: AsyncMock
640
+ ) -> None:
641
+ mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "invalid_commands"})
642
+ mock_client.fail_workflow_task.side_effect = RuntimeError("failure report unavailable")
643
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
644
+ task = {
645
+ "task_id": "t-complete-invalid-report-fails",
646
+ "workflow_type": "test-wf",
647
+ "workflow_task_attempt": 2,
648
+ "history_events": [],
649
+ "arguments": '["hello"]',
650
+ "payload_codec": "json",
651
+ }
652
+
653
+ result = await worker._run_workflow_task(task)
654
+
655
+ assert result is None
656
+ mock_client.complete_workflow_task.assert_awaited_once()
657
+ mock_client.fail_workflow_task.assert_awaited_once()
530
658
 
531
659
  @pytest.mark.asyncio
532
660
  async def test_workflow_command_payload_warning_uses_client_policy(
@@ -701,13 +829,13 @@ class TestWorkflowTaskExecution:
701
829
  mock_client.fail_workflow_task.assert_not_called()
702
830
 
703
831
  @pytest.mark.asyncio
704
- async def test_update_task_completion_error_fails_task_for_fast_redispatch(
832
+ async def test_update_task_ambiguous_completion_error_preserves_command(
705
833
  self, mock_client: AsyncMock
706
834
  ) -> None:
707
- mock_client.complete_workflow_task.side_effect = TimeoutError("update completion timed out")
835
+ mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "task_not_leased"})
708
836
  worker = Worker(mock_client, task_queue="q1", workflows=[UpdateWorkflow], activities=[])
709
837
  task = {
710
- "task_id": "t-update-timeout",
838
+ "task_id": "t-update-not-leased",
711
839
  "workflow_type": "update-wf",
712
840
  "workflow_task_attempt": 3,
713
841
  "workflow_update_id": "upd-worker-1",
@@ -729,14 +857,47 @@ class TestWorkflowTaskExecution:
729
857
 
730
858
  result = await worker._run_workflow_task(task)
731
859
 
732
- assert result is None
860
+ assert result is not None
861
+ assert result[0]["type"] == "complete_update"
733
862
  mock_client.complete_workflow_task.assert_awaited_once()
734
- mock_client.fail_workflow_task.assert_awaited_once()
735
- call_kwargs = mock_client.fail_workflow_task.await_args.kwargs
736
- assert call_kwargs["task_id"] == "t-update-timeout"
737
- assert call_kwargs["workflow_task_attempt"] == 3
738
- assert call_kwargs["failure_type"] == "TimeoutError"
739
- assert "update completion timed out" in call_kwargs["message"]
863
+ mock_client.fail_workflow_task.assert_not_called()
864
+
865
+ @pytest.mark.asyncio
866
+ async def test_update_task_retries_transient_completion_error(
867
+ self, mock_client: AsyncMock
868
+ ) -> None:
869
+ mock_client.complete_workflow_task.side_effect = [
870
+ ServerError(503, {"reason": "server_busy"}),
871
+ {"outcome": "completed"},
872
+ ]
873
+ worker = Worker(mock_client, task_queue="q1", workflows=[UpdateWorkflow], activities=[])
874
+ task = {
875
+ "task_id": "t-update-retry",
876
+ "workflow_type": "update-wf",
877
+ "workflow_task_attempt": 3,
878
+ "workflow_update_id": "upd-worker-1",
879
+ "workflow_wait_kind": "update",
880
+ "history_events": [
881
+ {
882
+ "event_type": "UpdateAccepted",
883
+ "payload": {
884
+ "update_id": "upd-worker-1",
885
+ "update_name": "increment",
886
+ "arguments": serializer.encode([6], codec="json"),
887
+ "payload_codec": "json",
888
+ },
889
+ },
890
+ ],
891
+ "arguments": "[]",
892
+ "payload_codec": "json",
893
+ }
894
+
895
+ result = await worker._run_workflow_task(task)
896
+
897
+ assert result is not None
898
+ assert result[0]["type"] == "complete_update"
899
+ assert mock_client.complete_workflow_task.await_count == 2
900
+ mock_client.fail_workflow_task.assert_not_called()
740
901
 
741
902
  @pytest.mark.asyncio
742
903
  async def test_query_task_executes_registered_query(self, mock_client: AsyncMock) -> None: