durable-workflow 0.4.45__tar.gz → 0.4.46__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.46}/PKG-INFO +1 -1
  2. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/pyproject.toml +1 -1
  3. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/worker.py +38 -6
  4. {durable_workflow-0.4.45 → durable_workflow-0.4.46/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  5. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_worker.py +113 -14
  6. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/LICENSE +0 -0
  7. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/README.md +0 -0
  8. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/setup.cfg +0 -0
  9. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/__init__.py +0 -0
  10. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/_avro.py +0 -0
  11. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/activity.py +0 -0
  12. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/auth_composition.py +0 -0
  13. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/client.py +0 -0
  14. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/errors.py +0 -0
  15. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/external_storage.py +0 -0
  16. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/external_task_input.py +0 -0
  17. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/external_task_result.py +0 -0
  18. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/history_bundle_verify.py +0 -0
  19. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/interceptors.py +0 -0
  20. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/invocable.py +0 -0
  21. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/metrics.py +0 -0
  22. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/py.typed +0 -0
  23. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/replay_verify.py +0 -0
  24. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/retry_policy.py +0 -0
  25. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/serializer.py +0 -0
  26. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/sync.py +0 -0
  27. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/testing.py +0 -0
  28. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow/workflow.py +0 -0
  29. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  30. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  31. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  32. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow.egg-info/requires.txt +0 -0
  33. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/src/durable_workflow.egg-info/top_level.txt +0 -0
  34. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_activity_context.py +0 -0
  35. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_auth_composition.py +0 -0
  36. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_client.py +0 -0
  37. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_control_plane_parity_fixtures.py +0 -0
  38. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_errors.py +0 -0
  39. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_external_storage.py +0 -0
  40. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_external_task_input.py +0 -0
  41. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_external_task_result.py +0 -0
  42. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_golden_history_replay.py +0 -0
  43. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_history_bundle_verify.py +0 -0
  44. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_history_event_contract.py +0 -0
  45. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_invocable.py +0 -0
  46. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_metrics.py +0 -0
  47. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_order_processing_example.py +0 -0
  48. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_public_boundary_scanner.py +0 -0
  49. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_queries.py +0 -0
  50. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_readme_quickstart.py +0 -0
  51. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_replay.py +0 -0
  52. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_replay_verify.py +0 -0
  53. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_retry_policy.py +0 -0
  54. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_schedules.py +0 -0
  55. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_serializer.py +0 -0
  56. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_signals.py +0 -0
  57. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_sleep.py +0 -0
  58. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_standalone_activity_client.py +0 -0
  59. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_sync.py +0 -0
  60. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_testing_harness.py +0 -0
  61. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/tests/test_updates.py +0 -0
  62. {durable_workflow-0.4.45 → durable_workflow-0.4.46}/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.46
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.46"
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,15 @@ _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
+ }
77
94
  _WORKER_WORKFLOW_FINGERPRINTS: dict[tuple[str, str], str] = {}
78
95
 
79
96
 
@@ -111,6 +128,19 @@ def _is_final_query_task_rejection(error: BaseException) -> bool:
111
128
  )
112
129
 
113
130
 
131
+ def _should_fail_workflow_task_after_completion_error(error: BaseException) -> bool:
132
+ if isinstance(error, InvalidArgument):
133
+ return True
134
+
135
+ if isinstance(error, ServerError):
136
+ if error.status >= 500 or error.status == 429:
137
+ return False
138
+
139
+ return error.reason() not in _WORKFLOW_TASK_COMPLETION_AMBIGUOUS_REJECTION_REASONS
140
+
141
+ return isinstance(error, DurableWorkflowError)
142
+
143
+
114
144
  def _signal_arguments_envelope_from_export(
115
145
  signal: Mapping[str, Any],
116
146
  *,
@@ -775,8 +805,9 @@ class Worker:
775
805
  )
776
806
  except Exception as e:
777
807
  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
808
+ if _should_fail_workflow_task_after_completion_error(e):
809
+ await self._report_workflow_task_after_completion_error(task_id, attempt, e)
810
+ return None
780
811
  return [command]
781
812
 
782
813
  try:
@@ -849,11 +880,12 @@ class Worker:
849
880
  )
850
881
  except Exception as e:
851
882
  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
883
+ if _should_fail_workflow_task_after_completion_error(e):
884
+ await self._report_workflow_task_after_completion_error(task_id, attempt, e)
885
+ return None
854
886
  return commands
855
887
 
856
- async def _fail_workflow_task_after_completion_error(
888
+ async def _report_workflow_task_after_completion_error(
857
889
  self,
858
890
  task_id: str,
859
891
  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.46
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,7 +524,7 @@ 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(
506
528
  self, mock_client: AsyncMock
507
529
  ) -> None:
508
530
  mock_client.complete_workflow_task.side_effect = TimeoutError("completion timed out")
@@ -518,15 +540,96 @@ class TestWorkflowTaskExecution:
518
540
 
519
541
  result = await worker._run_workflow_task(task)
520
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_definite_completion_rejection_fails_task(
550
+ self, mock_client: AsyncMock
551
+ ) -> None:
552
+ mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "invalid_commands"})
553
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
554
+ task = {
555
+ "task_id": "t-complete-invalid",
556
+ "workflow_type": "test-wf",
557
+ "workflow_task_attempt": 2,
558
+ "history_events": [],
559
+ "arguments": '["hello"]',
560
+ "payload_codec": "json",
561
+ }
562
+
563
+ result = await worker._run_workflow_task(task)
564
+
565
+ assert result is None
566
+ mock_client.fail_workflow_task.assert_awaited_once()
567
+ call_kwargs = mock_client.fail_workflow_task.await_args.kwargs
568
+ assert call_kwargs["task_id"] == "t-complete-invalid"
569
+ assert call_kwargs["workflow_task_attempt"] == 2
570
+ assert call_kwargs["lease_owner"] == worker.worker_id
571
+ assert call_kwargs["failure_type"] == "ServerError"
572
+ assert "invalid_commands" in call_kwargs["message"]
573
+
574
+ @pytest.mark.parametrize(
575
+ ("completion_error", "failure_type", "message_fragment"),
576
+ [
577
+ (Unauthorized("missing bearer token"), "Unauthorized", "missing bearer token"),
578
+ (WorkflowNotFound("wf-typed-missing"), "WorkflowNotFound", "wf-typed-missing"),
579
+ ],
580
+ )
581
+ @pytest.mark.asyncio
582
+ async def test_workflow_task_typed_completion_rejection_fails_task(
583
+ self,
584
+ mock_client: AsyncMock,
585
+ completion_error: Exception,
586
+ failure_type: str,
587
+ message_fragment: str,
588
+ ) -> None:
589
+ mock_client.complete_workflow_task.side_effect = completion_error
590
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
591
+ task = {
592
+ "task_id": "t-complete-typed-rejection",
593
+ "workflow_type": "test-wf",
594
+ "workflow_task_attempt": 2,
595
+ "history_events": [],
596
+ "arguments": '["hello"]',
597
+ "payload_codec": "json",
598
+ }
599
+
600
+ result = await worker._run_workflow_task(task)
601
+
521
602
  assert result is None
522
603
  mock_client.complete_workflow_task.assert_awaited_once()
523
604
  mock_client.fail_workflow_task.assert_awaited_once()
524
605
  call_kwargs = mock_client.fail_workflow_task.await_args.kwargs
525
- assert call_kwargs["task_id"] == "t-complete-timeout"
606
+ assert call_kwargs["task_id"] == "t-complete-typed-rejection"
526
607
  assert call_kwargs["workflow_task_attempt"] == 2
527
608
  assert call_kwargs["lease_owner"] == worker.worker_id
528
- assert call_kwargs["failure_type"] == "TimeoutError"
529
- assert "completion timed out" in call_kwargs["message"]
609
+ assert call_kwargs["failure_type"] == failure_type
610
+ assert message_fragment in call_kwargs["message"]
611
+
612
+ @pytest.mark.asyncio
613
+ async def test_workflow_task_definite_completion_rejection_stays_failed_when_report_fails(
614
+ self, mock_client: AsyncMock
615
+ ) -> None:
616
+ mock_client.complete_workflow_task.side_effect = ServerError(409, {"reason": "invalid_commands"})
617
+ mock_client.fail_workflow_task.side_effect = RuntimeError("failure report unavailable")
618
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
619
+ task = {
620
+ "task_id": "t-complete-invalid-report-fails",
621
+ "workflow_type": "test-wf",
622
+ "workflow_task_attempt": 2,
623
+ "history_events": [],
624
+ "arguments": '["hello"]',
625
+ "payload_codec": "json",
626
+ }
627
+
628
+ result = await worker._run_workflow_task(task)
629
+
630
+ assert result is None
631
+ mock_client.complete_workflow_task.assert_awaited_once()
632
+ mock_client.fail_workflow_task.assert_awaited_once()
530
633
 
531
634
  @pytest.mark.asyncio
532
635
  async def test_workflow_command_payload_warning_uses_client_policy(
@@ -701,7 +804,7 @@ class TestWorkflowTaskExecution:
701
804
  mock_client.fail_workflow_task.assert_not_called()
702
805
 
703
806
  @pytest.mark.asyncio
704
- async def test_update_task_completion_error_fails_task_for_fast_redispatch(
807
+ async def test_update_task_ambiguous_completion_error_preserves_command(
705
808
  self, mock_client: AsyncMock
706
809
  ) -> None:
707
810
  mock_client.complete_workflow_task.side_effect = TimeoutError("update completion timed out")
@@ -729,14 +832,10 @@ class TestWorkflowTaskExecution:
729
832
 
730
833
  result = await worker._run_workflow_task(task)
731
834
 
732
- assert result is None
835
+ assert result is not None
836
+ assert result[0]["type"] == "complete_update"
733
837
  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"]
838
+ mock_client.fail_workflow_task.assert_not_called()
740
839
 
741
840
  @pytest.mark.asyncio
742
841
  async def test_query_task_executes_registered_query(self, mock_client: AsyncMock) -> None: