durable-workflow 2.3.3__tar.gz → 2.3.5__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.5}/PKG-INFO +1 -1
  2. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/pyproject.toml +3 -3
  3. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/client.py +70 -34
  4. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/retry_policy.py +30 -0
  5. {durable_workflow-2.3.3 → durable_workflow-2.3.5/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  6. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_client.py +47 -0
  7. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_storage_admission.py +63 -4
  8. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_workflow_result_timeout.py +3 -1
  9. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/LICENSE +0 -0
  10. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/README.md +0 -0
  11. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/setup.cfg +0 -0
  12. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/__init__.py +0 -0
  13. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/_avro.py +0 -0
  14. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/activity.py +0 -0
  15. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/auth_composition.py +0 -0
  16. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/errors.py +0 -0
  17. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_storage.py +0 -0
  18. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_task_input.py +0 -0
  19. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/external_task_result.py +0 -0
  20. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/history_bundle_verify.py +0 -0
  21. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/interceptors.py +0 -0
  22. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/invocable.py +0 -0
  23. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/metrics.py +0 -0
  24. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/nexus.py +0 -0
  25. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/py.typed +0 -0
  26. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/python_conformance.py +0 -0
  27. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/replay_conformance.py +0 -0
  28. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/replay_verify.py +0 -0
  29. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/serializer.py +0 -0
  30. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/sync.py +0 -0
  31. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/testing.py +0 -0
  32. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/worker.py +0 -0
  33. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/worker_session.py +0 -0
  34. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/workflow.py +0 -0
  35. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  36. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  37. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  38. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  39. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/requires.txt +0 -0
  40. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/src/durable_workflow.egg-info/top_level.txt +0 -0
  41. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_activity_context.py +0 -0
  42. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_auth_composition.py +0 -0
  43. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_avro_value_benchmark.py +0 -0
  44. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_avro_value_protocol.py +0 -0
  45. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_ci_checkout.py +0 -0
  46. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_ci_integration_endpoint.py +0 -0
  47. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_codec_regression_corpus.py +0 -0
  48. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_control_plane_parity_fixtures.py +0 -0
  49. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_docstring_cross_references.py +0 -0
  50. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_durable_selection.py +0 -0
  51. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_errors.py +0 -0
  52. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_external_storage.py +0 -0
  53. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_external_task_input.py +0 -0
  54. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_external_task_result.py +0 -0
  55. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_golden_history_replay.py +0 -0
  56. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_history_bundle_verify.py +0 -0
  57. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_history_event_contract.py +0 -0
  58. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_invocable.py +0 -0
  59. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_message_streams.py +0 -0
  60. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_metrics.py +0 -0
  61. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_order_processing_example.py +0 -0
  62. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_parallel_completion_order_replay.py +0 -0
  63. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_parallel_saga_authoring.py +0 -0
  64. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_public_boundary_scanner.py +0 -0
  65. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_pypi_project_surface.py +0 -0
  66. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_python_conformance.py +0 -0
  67. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_queries.py +0 -0
  68. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_readme_quickstart.py +0 -0
  69. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_redrive_replay.py +0 -0
  70. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_release_metadata.py +0 -0
  71. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay.py +0 -0
  72. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay_conformance.py +0 -0
  73. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay_regression_corpus.py +0 -0
  74. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_replay_verify.py +0 -0
  75. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_retry_policy.py +0 -0
  76. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_runtime_external_payload_transport.py +0 -0
  77. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_schedules.py +0 -0
  78. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_serializer.py +0 -0
  79. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_signals.py +0 -0
  80. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_sleep.py +0 -0
  81. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_standalone_activity_client.py +0 -0
  82. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_sync.py +0 -0
  83. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_testing_harness.py +0 -0
  84. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_update_signal_condition_replay.py +0 -0
  85. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_updates.py +0 -0
  86. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_wait_condition.py +0 -0
  87. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_worker.py +0 -0
  88. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_worker_session.py +0 -0
  89. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/tests/test_workflow_streams.py +0 -0
  90. {durable_workflow-2.3.3 → durable_workflow-2.3.5}/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.5
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.5"
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.5"
75
+ registry-version = "2.3.5"
76
76
  supported-server-versions = "2.4.8"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -1271,11 +1271,21 @@ class WorkflowHandle:
1271
1271
  """Return the server's current view of this workflow. See :meth:`Client.describe_workflow`."""
1272
1272
  return await self._client.describe_workflow(self.workflow_id)
1273
1273
 
1274
- async def get_history(self) -> Any:
1275
- """Fetch this run's durable history. See :meth:`Client.get_history`."""
1274
+ async def get_history(
1275
+ self,
1276
+ *,
1277
+ page_size: int | None = None,
1278
+ next_page_token: str | None = None,
1279
+ ) -> Any:
1280
+ """Fetch one page of this run's history. See :meth:`Client.get_history`."""
1276
1281
  if self.run_id is None:
1277
1282
  raise ValueError("run_id is required to fetch workflow history from a handle")
1278
- return await self._client.get_history(self.workflow_id, self.run_id)
1283
+ options: dict[str, Any] = {}
1284
+ if page_size is not None:
1285
+ options["page_size"] = page_size
1286
+ if next_page_token is not None:
1287
+ options["next_page_token"] = next_page_token
1288
+ return await self._client.get_history(self.workflow_id, self.run_id, **options)
1279
1289
 
1280
1290
  async def export_history(self) -> Any:
1281
1291
  """Export this run's history as a replay bundle. See :meth:`Client.export_history`."""
@@ -3462,10 +3472,26 @@ class Client:
3462
3472
  next_page_token=data.get("next_page_token"),
3463
3473
  )
3464
3474
 
3465
- async def get_history(self, workflow_id: str, run_id: str) -> Any:
3466
- """Fetch the full durable history for one specific run of a workflow."""
3475
+ async def get_history(
3476
+ self,
3477
+ workflow_id: str,
3478
+ run_id: str,
3479
+ *,
3480
+ page_size: int | None = None,
3481
+ next_page_token: str | None = None,
3482
+ ) -> Any:
3483
+ """Fetch one history page; pass its opaque next_page_token for the next page."""
3484
+ params: dict[str, str] = {}
3485
+ if page_size is not None:
3486
+ params["page_size"] = str(page_size)
3487
+ if next_page_token is not None:
3488
+ params["next_page_token"] = next_page_token
3489
+ query = urlencode(params)
3490
+ path = f"/workflows/{workflow_id}/runs/{run_id}/history"
3491
+ if query:
3492
+ path += f"?{query}"
3467
3493
  return await self._request(
3468
- "GET", f"/workflows/{workflow_id}/runs/{run_id}/history", context=workflow_id
3494
+ "GET", path, context=workflow_id
3469
3495
  )
3470
3496
 
3471
3497
  async def export_history(self, workflow_id: str, run_id: str) -> Any:
@@ -4240,34 +4266,44 @@ class Client:
4240
4266
  run_id = handle.run_id or desc.run_id
4241
4267
  if run_id is None:
4242
4268
  raise WorkflowFailed("no run_id available to fetch history")
4243
- history = await self.get_history(handle.workflow_id, run_id)
4244
- events = history.get("events", [])
4245
- for ev in reversed(events):
4246
- etype = ev.get("event_type")
4247
- payload = ev.get("payload") or {}
4248
- if etype == "WorkflowCompleted":
4249
- return serializer.decode_envelope(
4250
- payload.get("output"),
4251
- codec=payload.get("payload_codec") or desc.payload_codec,
4252
- external_storage=self.external_storage,
4253
- external_storage_cache=self.external_storage_cache,
4254
- )
4255
- if etype == "WorkflowFailed":
4256
- raise WorkflowFailed(
4257
- payload.get("message", "workflow failed"),
4258
- payload.get("exception_class"),
4259
- )
4260
- if etype == "WorkflowTerminated":
4261
- raise WorkflowTerminated(
4262
- payload.get("reason", "workflow was terminated")
4263
- )
4264
- if etype == "WorkflowCancelled":
4265
- raise WorkflowCancelled(
4266
- payload.get("reason", "workflow was cancelled")
4267
- )
4268
- if etype == "WorkflowTimedOut":
4269
- raise WorkflowTimedOut()
4270
- return None
4269
+ page_token: str | None = None
4270
+ seen_tokens: set[str] = set()
4271
+ while True:
4272
+ history = await self.get_history(
4273
+ handle.workflow_id, run_id, page_size=1000, next_page_token=page_token
4274
+ )
4275
+ events = history.get("events", [])
4276
+ for ev in reversed(events):
4277
+ etype = ev.get("event_type")
4278
+ payload = ev.get("payload") or {}
4279
+ if etype == "WorkflowCompleted":
4280
+ return serializer.decode_envelope(
4281
+ payload.get("output"),
4282
+ codec=payload.get("payload_codec") or desc.payload_codec,
4283
+ external_storage=self.external_storage,
4284
+ external_storage_cache=self.external_storage_cache,
4285
+ )
4286
+ if etype == "WorkflowFailed":
4287
+ raise WorkflowFailed(
4288
+ payload.get("message", "workflow failed"),
4289
+ payload.get("exception_class"),
4290
+ )
4291
+ if etype == "WorkflowTerminated":
4292
+ raise WorkflowTerminated(
4293
+ payload.get("reason", "workflow was terminated")
4294
+ )
4295
+ if etype == "WorkflowCancelled":
4296
+ raise WorkflowCancelled(
4297
+ payload.get("reason", "workflow was cancelled")
4298
+ )
4299
+ if etype == "WorkflowTimedOut":
4300
+ raise WorkflowTimedOut()
4301
+ page_token = history.get("next_page_token")
4302
+ if not page_token:
4303
+ return None
4304
+ if page_token in seen_tokens:
4305
+ raise RuntimeError("history pagination repeated a page token")
4306
+ seen_tokens.add(page_token)
4271
4307
  if asyncio.get_running_loop().time() > deadline:
4272
4308
  raise TimeoutError(
4273
4309
  f"workflow {handle.workflow_id} not terminal after {timeout}s (status={status})"
@@ -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.5
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
@@ -791,6 +791,17 @@ class TestWorkflowRunVisibility:
791
791
 
792
792
 
793
793
  class TestWorkflowHandleControlPlane:
794
+ @pytest.mark.asyncio
795
+ async def test_history_page_token_is_encoded(self, client: Client) -> None:
796
+ response = _mock_response(200, {"events": [], "next_page_token": None})
797
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=response) as request:
798
+ page = await client.get_history("wf-1", "run-1", page_size=1000, next_page_token="opaque+/=")
799
+
800
+ assert page["events"] == []
801
+ assert request.call_args.args[1] == (
802
+ "/api/workflows/wf-1/runs/run-1/history?page_size=1000&next_page_token=opaque%2B%2F%3D"
803
+ )
804
+
794
805
  @pytest.mark.asyncio
795
806
  async def test_run_visibility_delegates_to_client(self, client: Client) -> None:
796
807
  handle = WorkflowHandle(client, workflow_id="wf-1", run_id="r1", workflow_type="greeter")
@@ -3181,6 +3192,42 @@ class TestUpdateWorkflow:
3181
3192
 
3182
3193
 
3183
3194
  class TestGetResult:
3195
+ @pytest.mark.asyncio
3196
+ async def test_completed_result_on_later_history_page(self, client: Client) -> None:
3197
+ handle = WorkflowHandle(client, workflow_id="wf-1", run_id="run-1", workflow_type="greeter")
3198
+ client.describe_workflow = AsyncMock(
3199
+ return_value=WorkflowExecution(
3200
+ workflow_id="wf-1", run_id="run-1", workflow_type="greeter", status="completed",
3201
+ )
3202
+ )
3203
+ client.get_history = AsyncMock(side_effect=[
3204
+ {"events": [{"event_type": "WorkflowStarted"}], "next_page_token": "page two"},
3205
+ {"events": [{"event_type": "WorkflowCompleted", "payload": {
3206
+ "output": serializer.encode(42, codec="avro"), "payload_codec": "avro",
3207
+ }}], "next_page_token": None},
3208
+ ])
3209
+
3210
+ assert await client.get_result(handle) == 42
3211
+ assert client.get_history.await_args_list[0].args == ("wf-1", "run-1")
3212
+ assert client.get_history.await_args_list[0].kwargs == {"page_size": 1000, "next_page_token": None}
3213
+ assert client.get_history.await_args_list[1].kwargs == {
3214
+ "page_size": 1000, "next_page_token": "page two",
3215
+ }
3216
+
3217
+ @pytest.mark.asyncio
3218
+ async def test_repeated_history_page_token_fails(self, client: Client) -> None:
3219
+ handle = WorkflowHandle(client, workflow_id="wf-1", run_id="run-1", workflow_type="greeter")
3220
+ client.describe_workflow = AsyncMock(
3221
+ return_value=WorkflowExecution(
3222
+ workflow_id="wf-1", run_id="run-1", workflow_type="greeter", status="completed",
3223
+ )
3224
+ )
3225
+ client.get_history = AsyncMock(return_value={"events": [], "next_page_token": "same"})
3226
+
3227
+ with pytest.raises(RuntimeError, match="repeated a page token"):
3228
+ await client.get_result(handle)
3229
+ assert client.get_history.await_count == 2
3230
+
3184
3231
  @pytest.mark.asyncio
3185
3232
  async def test_completed_result_uses_event_payload_codec(self, client: Client) -> None:
3186
3233
  handle = WorkflowHandle(client, workflow_id="wf-1", run_id="run-1", workflow_type="greeter")
@@ -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"},
@@ -28,7 +28,9 @@ async def test_persisted_deadline_raises_typed_timeout_for_selected_history(kind
28
28
  try:
29
29
  with pytest.raises(WorkflowTimedOut, match="workflow execution timed out"):
30
30
  await client.get_result(handle, timeout=0)
31
- client.get_history.assert_awaited_once_with("order", "selected-run")
31
+ client.get_history.assert_awaited_once_with(
32
+ "order", "selected-run", page_size=1000, next_page_token=None
33
+ )
32
34
  finally:
33
35
  await client.aclose()
34
36