durable-workflow 2.3.3__tar.gz → 2.3.4__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 (90) hide show
  1. {durable_workflow-2.3.3/src/durable_workflow.egg-info → durable_workflow-2.3.4}/PKG-INFO +1 -1
  2. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/pyproject.toml +3 -3
  3. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/retry_policy.py +30 -0
  4. {durable_workflow-2.3.3 → durable_workflow-2.3.4/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  5. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_storage_admission.py +63 -4
  6. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/LICENSE +0 -0
  7. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/README.md +0 -0
  8. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/setup.cfg +0 -0
  9. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/__init__.py +0 -0
  10. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/_avro.py +0 -0
  11. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/activity.py +0 -0
  12. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/auth_composition.py +0 -0
  13. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/client.py +0 -0
  14. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/errors.py +0 -0
  15. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/external_storage.py +0 -0
  16. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/external_task_input.py +0 -0
  17. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/external_task_result.py +0 -0
  18. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/history_bundle_verify.py +0 -0
  19. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/interceptors.py +0 -0
  20. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/invocable.py +0 -0
  21. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/metrics.py +0 -0
  22. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/nexus.py +0 -0
  23. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/py.typed +0 -0
  24. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/python_conformance.py +0 -0
  25. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/replay_conformance.py +0 -0
  26. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/replay_verify.py +0 -0
  27. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/serializer.py +0 -0
  28. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/sync.py +0 -0
  29. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/testing.py +0 -0
  30. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/worker.py +0 -0
  31. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/worker_session.py +0 -0
  32. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/workflow.py +0 -0
  33. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  34. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  35. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  36. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  37. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/requires.txt +0 -0
  38. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/top_level.txt +0 -0
  39. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_activity_context.py +0 -0
  40. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_auth_composition.py +0 -0
  41. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_avro_value_benchmark.py +0 -0
  42. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_avro_value_protocol.py +0 -0
  43. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_ci_checkout.py +0 -0
  44. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_ci_integration_endpoint.py +0 -0
  45. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_client.py +0 -0
  46. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_codec_regression_corpus.py +0 -0
  47. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_control_plane_parity_fixtures.py +0 -0
  48. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_docstring_cross_references.py +0 -0
  49. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_durable_selection.py +0 -0
  50. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_errors.py +0 -0
  51. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_external_storage.py +0 -0
  52. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_external_task_input.py +0 -0
  53. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_external_task_result.py +0 -0
  54. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_golden_history_replay.py +0 -0
  55. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_history_bundle_verify.py +0 -0
  56. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_history_event_contract.py +0 -0
  57. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_invocable.py +0 -0
  58. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_message_streams.py +0 -0
  59. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_metrics.py +0 -0
  60. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_order_processing_example.py +0 -0
  61. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_parallel_completion_order_replay.py +0 -0
  62. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_parallel_saga_authoring.py +0 -0
  63. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_public_boundary_scanner.py +0 -0
  64. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_pypi_project_surface.py +0 -0
  65. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_python_conformance.py +0 -0
  66. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_queries.py +0 -0
  67. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_readme_quickstart.py +0 -0
  68. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_redrive_replay.py +0 -0
  69. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_release_metadata.py +0 -0
  70. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_replay.py +0 -0
  71. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_replay_conformance.py +0 -0
  72. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_replay_regression_corpus.py +0 -0
  73. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_replay_verify.py +0 -0
  74. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_retry_policy.py +0 -0
  75. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_runtime_external_payload_transport.py +0 -0
  76. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_schedules.py +0 -0
  77. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_serializer.py +0 -0
  78. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_signals.py +0 -0
  79. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_sleep.py +0 -0
  80. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_standalone_activity_client.py +0 -0
  81. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_sync.py +0 -0
  82. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_testing_harness.py +0 -0
  83. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_update_signal_condition_replay.py +0 -0
  84. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_updates.py +0 -0
  85. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_wait_condition.py +0 -0
  86. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_worker.py +0 -0
  87. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_worker_session.py +0 -0
  88. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_workflow_result_timeout.py +0 -0
  89. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/tests/test_workflow_streams.py +0 -0
  90. {durable_workflow-2.3.3 → durable_workflow-2.3.4}/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.3.3
3
+ Version: 2.3.4
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.3.3"
7
+ version = "2.3.4"
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.3.3"
75
- registry-version = "2.3.3"
74
+ product-train = "2.3.4"
75
+ registry-version = "2.3.4"
76
76
  supported-server-versions = "2.4.8"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -80,6 +80,18 @@ def _backend_unavailable_refusal(exc: Exception) -> tuple[bool, int | None]:
80
80
  "/api/worker/heartbeat": "heartbeat_worker",
81
81
  }
82
82
  operation = next((name for path, name in operations.items() if request.url.path.endswith(path)), None)
83
+ _, task_heartbeat_marker, task_heartbeat_tail = request.url.path.rpartition(
84
+ "/api/worker/workflow-tasks/"
85
+ )
86
+ task_id = task_heartbeat_tail.removesuffix("/heartbeat")
87
+ task_heartbeat = (
88
+ bool(task_heartbeat_marker)
89
+ and task_heartbeat_tail.endswith("/heartbeat")
90
+ and bool(task_id)
91
+ and "/" not in task_id
92
+ )
93
+ if task_heartbeat:
94
+ operation = "heartbeat_workflow_task"
83
95
  if operation is None:
84
96
  return False, None
85
97
  try:
@@ -97,6 +109,24 @@ def _backend_unavailable_refusal(exc: Exception) -> tuple[bool, int | None]:
97
109
  worker_id = submitted.get("worker_id")
98
110
  queue = submitted.get("task_queue")
99
111
  delay = body.get("retry_after_seconds")
112
+ if task_heartbeat:
113
+ lease_owner = submitted.get("lease_owner")
114
+ attempt = submitted.get("workflow_task_attempt")
115
+ if (
116
+ not isinstance(lease_owner, str) or not lease_owner
117
+ or type(attempt) is not int or attempt <= 0
118
+ or body.get("operation") != operation
119
+ or body.get("outcome") != "unknown"
120
+ or body.get("worker_id") != lease_owner
121
+ or body.get("task_queue") is not None
122
+ or body.get("task_id") != task_id
123
+ or body.get("lease_owner") != lease_owner
124
+ or body.get("workflow_task_attempt") != attempt
125
+ or body.get("retryable") is not True
126
+ or type(delay) is not int or delay <= 0
127
+ ):
128
+ return True, None
129
+ return True, delay
100
130
  if (
101
131
  not isinstance(worker_id, str) or not worker_id
102
132
  or body.get("operation") != operation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.3.3
3
+ Version: 2.3.4
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
@@ -42,7 +42,8 @@ def pressure(poll_id: str | None = None, *, reason: str = "storage_pressure", en
42
42
  def backend_unavailable(request: httpx.Request) -> dict[str, Any]:
43
43
  submitted = json.loads(request.content)
44
44
  path = request.url.path
45
- operation = {
45
+ task_heartbeat = "/api/worker/workflow-tasks/" in path and path.endswith("/heartbeat")
46
+ operation = "heartbeat_workflow_task" if task_heartbeat else {
46
47
  "/api/worker/workflow-tasks/poll": "poll_workflow_task",
47
48
  "/api/worker/activity-tasks/poll": "poll_activity_task",
48
49
  "/api/worker/query-tasks/poll": "poll_query_task",
@@ -51,9 +52,16 @@ def backend_unavailable(request: httpx.Request) -> dict[str, Any]:
51
52
  }[path]
52
53
  body: dict[str, Any] = {
53
54
  "reason": "backend_unavailable", "operation": operation, "outcome": "unknown",
54
- "worker_id": submitted["worker_id"], "task_queue": submitted.get("task_queue"),
55
+ "worker_id": submitted.get("lease_owner") if task_heartbeat else submitted["worker_id"],
56
+ "task_queue": submitted.get("task_queue"),
55
57
  "retryable": True, "retry_after_seconds": 1,
56
58
  }
59
+ if task_heartbeat:
60
+ body.update({
61
+ "task_id": path.rpartition("/api/worker/workflow-tasks/")[2].removesuffix("/heartbeat"),
62
+ "lease_owner": submitted["lease_owner"],
63
+ "workflow_task_attempt": submitted["workflow_task_attempt"],
64
+ })
57
65
  if path.endswith("/poll"):
58
66
  body.update({
59
67
  "task": None, "poll_status": "backend_unavailable",
@@ -85,9 +93,9 @@ def retry_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
85
93
  return sleeps
86
94
 
87
95
 
88
- def client_for(handler: Callable[..., Any]) -> Client:
96
+ def client_for(handler: Callable[..., Any], base_url: str = "https://runtime.example") -> Client:
89
97
  client = Client(
90
- "https://runtime.example", token="test-runtime-token",
98
+ base_url, token="test-runtime-token",
91
99
  retry_policy=TransportRetryPolicy(max_attempts=2, initial_backoff_seconds=0, jitter=False),
92
100
  )
93
101
  client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handler))
@@ -186,6 +194,57 @@ async def test_backend_outage_retries_worker_registration_and_heartbeat(
186
194
  assert sum(retry_sleeps) == pytest.approx(4)
187
195
 
188
196
 
197
+ @pytest.mark.parametrize("base_url", ["https://runtime.example", "https://runtime.example/managed/namespace"])
198
+ async def test_backend_outage_retries_workflow_task_heartbeat_with_same_fence(
199
+ base_url: str, retry_sleeps: list[float],
200
+ ) -> None:
201
+ requests: list[bytes] = []
202
+
203
+ def handler(request: httpx.Request) -> httpx.Response:
204
+ requests.append(request.content)
205
+ if len(requests) <= 4:
206
+ return httpx.Response(503, json=backend_unavailable(request))
207
+ return httpx.Response(200, json={
208
+ "task_id": "task-1", "lease_owner": "worker-1",
209
+ "workflow_task_attempt": 3, "renewed": True,
210
+ })
211
+
212
+ async with client_for(handler, base_url) as client:
213
+ with worker_scope():
214
+ result = await client.heartbeat_workflow_task(
215
+ task_id="task-1", lease_owner="worker-1", workflow_task_attempt=3,
216
+ )
217
+ assert result["renewed"] is True
218
+ assert len(requests) == 5
219
+ assert len(set(requests)) == 1
220
+ assert sum(retry_sleeps) == pytest.approx(4)
221
+
222
+
223
+ @pytest.mark.parametrize("override", [
224
+ {"task_id": "other-task"}, {"lease_owner": "other-worker"},
225
+ {"worker_id": "other-worker"}, {"workflow_task_attempt": 4},
226
+ {"operation": "heartbeat_worker"}, {"outcome": "failed"},
227
+ {"retryable": False}, {"retry_after_seconds": 0},
228
+ ])
229
+ async def test_invalid_workflow_task_heartbeat_backend_response_is_not_retried(
230
+ override: dict[str, Any], retry_sleeps: list[float],
231
+ ) -> None:
232
+ calls = 0
233
+
234
+ def handler(request: httpx.Request) -> httpx.Response:
235
+ nonlocal calls
236
+ calls += 1
237
+ return httpx.Response(503, json={**backend_unavailable(request), **override})
238
+
239
+ async with client_for(handler) as client:
240
+ with worker_scope(), pytest.raises(ServerError):
241
+ await client.heartbeat_workflow_task(
242
+ task_id="task-1", lease_owner="worker-1", workflow_task_attempt=3,
243
+ )
244
+ assert calls == 1
245
+ assert not retry_sleeps
246
+
247
+
189
248
  @pytest.mark.parametrize("override", [
190
249
  {"operation": "poll_activity_task"},
191
250
  {"outcome": "failed"}, {"worker_id": "other-worker"},