durable-workflow 2.3.0__tar.gz → 2.3.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 (90) hide show
  1. {durable_workflow-2.3.0/src/durable_workflow.egg-info → durable_workflow-2.3.3}/PKG-INFO +1 -1
  2. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/pyproject.toml +4 -4
  3. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/errors.py +19 -0
  4. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/retry_policy.py +101 -7
  5. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/worker.py +24 -0
  6. {durable_workflow-2.3.0 → durable_workflow-2.3.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_client.py +28 -0
  8. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_release_metadata.py +1 -1
  9. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_retry_policy.py +54 -0
  10. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_storage_admission.py +179 -0
  11. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_worker.py +54 -1
  12. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/LICENSE +0 -0
  13. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/README.md +0 -0
  14. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/setup.cfg +0 -0
  15. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/__init__.py +0 -0
  16. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/activity.py +0 -0
  18. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/auth_composition.py +0 -0
  19. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/client.py +0 -0
  20. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/external_storage.py +0 -0
  21. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/external_task_input.py +0 -0
  22. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/external_task_result.py +0 -0
  23. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/history_bundle_verify.py +0 -0
  24. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/interceptors.py +0 -0
  25. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/invocable.py +0 -0
  26. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/metrics.py +0 -0
  27. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/nexus.py +0 -0
  28. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/py.typed +0 -0
  29. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/python_conformance.py +0 -0
  30. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/replay_conformance.py +0 -0
  31. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/replay_verify.py +0 -0
  32. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/serializer.py +0 -0
  33. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/sync.py +0 -0
  34. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/testing.py +0 -0
  35. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/worker_session.py +0 -0
  36. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/workflow.py +0 -0
  37. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  38. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  39. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  40. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  41. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/requires.txt +0 -0
  42. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
  43. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_activity_context.py +0 -0
  44. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_auth_composition.py +0 -0
  45. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_avro_value_benchmark.py +0 -0
  46. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_avro_value_protocol.py +0 -0
  47. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_ci_checkout.py +0 -0
  48. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_ci_integration_endpoint.py +0 -0
  49. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_codec_regression_corpus.py +0 -0
  50. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_control_plane_parity_fixtures.py +0 -0
  51. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_docstring_cross_references.py +0 -0
  52. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_durable_selection.py +0 -0
  53. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_errors.py +0 -0
  54. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_external_storage.py +0 -0
  55. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_external_task_input.py +0 -0
  56. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_external_task_result.py +0 -0
  57. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_golden_history_replay.py +0 -0
  58. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_history_bundle_verify.py +0 -0
  59. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_history_event_contract.py +0 -0
  60. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_invocable.py +0 -0
  61. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_message_streams.py +0 -0
  62. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_metrics.py +0 -0
  63. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_order_processing_example.py +0 -0
  64. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_parallel_completion_order_replay.py +0 -0
  65. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_parallel_saga_authoring.py +0 -0
  66. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_public_boundary_scanner.py +0 -0
  67. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_pypi_project_surface.py +0 -0
  68. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_python_conformance.py +0 -0
  69. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_queries.py +0 -0
  70. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_readme_quickstart.py +0 -0
  71. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_redrive_replay.py +0 -0
  72. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_replay.py +0 -0
  73. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_replay_conformance.py +0 -0
  74. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_replay_regression_corpus.py +0 -0
  75. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_replay_verify.py +0 -0
  76. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_runtime_external_payload_transport.py +0 -0
  77. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_schedules.py +0 -0
  78. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_serializer.py +0 -0
  79. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_signals.py +0 -0
  80. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_sleep.py +0 -0
  81. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_standalone_activity_client.py +0 -0
  82. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_sync.py +0 -0
  83. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_testing_harness.py +0 -0
  84. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_update_signal_condition_replay.py +0 -0
  85. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_updates.py +0 -0
  86. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_wait_condition.py +0 -0
  87. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_worker_session.py +0 -0
  88. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_workflow_result_timeout.py +0 -0
  89. {durable_workflow-2.3.0 → durable_workflow-2.3.3}/tests/test_workflow_streams.py +0 -0
  90. {durable_workflow-2.3.0 → durable_workflow-2.3.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.3.0
3
+ Version: 2.3.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.3.0"
7
+ version = "2.3.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,9 +71,9 @@ 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.0"
75
- registry-version = "2.3.0"
76
- supported-server-versions = "2.4.2"
74
+ product-train = "2.3.3"
75
+ registry-version = "2.3.3"
76
+ supported-server-versions = "2.4.8"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
79
79
  durable-selection = true
@@ -171,6 +171,25 @@ class ServerError(DurableWorkflowError):
171
171
  return self.body.get("reason")
172
172
  return None
173
173
 
174
+ def poll_capacity_backpressure_delay(self, task_kind: str, task_queue: str) -> int | None:
175
+ """Return the advertised wait only for a complete poll-capacity refusal."""
176
+ body = self.body
177
+ if (
178
+ self.status != 429
179
+ or not isinstance(body, dict)
180
+ or body.get("reason") != "long_poll_capacity_exhausted"
181
+ or body.get("poll_status") != "long_poll_capacity_exhausted"
182
+ or "task" not in body or body["task"] is not None
183
+ or body.get("task_kind") != task_kind
184
+ or body.get("task_queue") != task_queue
185
+ or body.get("retryable") is not True
186
+ or type(body.get("retry_after_seconds")) is not int
187
+ or body["retry_after_seconds"] <= 0
188
+ or body["retry_after_seconds"] > 60
189
+ ):
190
+ return None
191
+ return int(body["retry_after_seconds"])
192
+
174
193
  def is_storage_admission_failure(self, poll_request_id: str | None = None) -> bool:
175
194
  """Whether the runtime explicitly refused admission and requested an identity-preserving retry."""
176
195
  body = self.body
@@ -65,6 +65,88 @@ def _storage_refusal(exc: Exception) -> tuple[ServerError, str | None] | None:
65
65
  return error, poll_id
66
66
 
67
67
 
68
+ def _backend_unavailable_refusal(exc: Exception) -> tuple[bool, int | None]:
69
+ if not isinstance(exc, httpx.HTTPStatusError) or exc.response.status_code != 503:
70
+ return False, None
71
+ request = exc.request
72
+ if request.method != "POST" or "X-Durable-Workflow-Protocol-Version" not in request.headers:
73
+ return False, None
74
+ operations = {
75
+ "/api/worker/workflow-tasks/poll": "poll_workflow_task",
76
+ "/api/worker/activity-tasks/poll": "poll_activity_task",
77
+ "/api/worker/query-tasks/poll": "poll_query_task",
78
+ "/api/worker/update-validation-tasks/poll": "poll_update_validation_task",
79
+ "/api/worker/register": "register_worker",
80
+ "/api/worker/heartbeat": "heartbeat_worker",
81
+ }
82
+ operation = next((name for path, name in operations.items() if request.url.path.endswith(path)), None)
83
+ if operation is None:
84
+ return False, None
85
+ try:
86
+ body = exc.response.json()
87
+ except ValueError:
88
+ return False, None
89
+ if not isinstance(body, dict) or body.get("reason") != "backend_unavailable":
90
+ return False, None
91
+ try:
92
+ submitted = json.loads(request.content)
93
+ except ValueError:
94
+ return True, None
95
+ if not isinstance(submitted, dict):
96
+ return True, None
97
+ worker_id = submitted.get("worker_id")
98
+ queue = submitted.get("task_queue")
99
+ delay = body.get("retry_after_seconds")
100
+ if (
101
+ not isinstance(worker_id, str) or not worker_id
102
+ or body.get("operation") != operation
103
+ or body.get("outcome") != "unknown"
104
+ or body.get("worker_id") != worker_id
105
+ or body.get("task_queue") != queue
106
+ or body.get("retryable") is not True
107
+ or type(delay) is not int or delay <= 0
108
+ or (operation != "heartbeat_worker" and (not isinstance(queue, str) or not queue))
109
+ ):
110
+ return True, None
111
+ if operation.startswith("poll_"):
112
+ poll_id = submitted.get("poll_request_id")
113
+ if (
114
+ not isinstance(poll_id, str) or not poll_id
115
+ or "task" not in body or body["task"] is not None
116
+ or body.get("poll_status") != "backend_unavailable"
117
+ or body.get("poll_request_id") != poll_id
118
+ or body.get("retry_same_poll_request_id") is not True
119
+ ):
120
+ return True, None
121
+ return True, delay
122
+
123
+
124
+ def _poll_capacity_refusal(exc: Exception) -> bool:
125
+ if not isinstance(exc, httpx.HTTPStatusError) or exc.response.status_code != 429:
126
+ return False
127
+ request = exc.request
128
+ if request.method != "POST" or "X-Durable-Workflow-Protocol-Version" not in request.headers:
129
+ return False
130
+ task_kinds = {
131
+ "/api/worker/workflow-tasks/poll": "workflow_task",
132
+ "/api/worker/activity-tasks/poll": "activity_task",
133
+ "/api/worker/query-tasks/poll": "query_task",
134
+ }
135
+ task_kind = next((kind for path, kind in task_kinds.items() if request.url.path.endswith(path)), None)
136
+ if task_kind is None:
137
+ return False
138
+ try:
139
+ body = exc.response.json()
140
+ submitted = json.loads(request.content)
141
+ except ValueError:
142
+ return False
143
+ if not isinstance(submitted, dict) or not isinstance(submitted.get("task_queue"), str):
144
+ return False
145
+ return ServerError(429, body).poll_capacity_backpressure_delay(
146
+ task_kind, submitted["task_queue"],
147
+ ) is not None
148
+
149
+
68
150
  @dataclass
69
151
  class TransportRetryPolicy:
70
152
  """
@@ -101,6 +183,8 @@ class TransportRetryPolicy:
101
183
 
102
184
  # Retry 5xx server errors and 429 rate limit
103
185
  if isinstance(exc, httpx.HTTPStatusError):
186
+ if _poll_capacity_refusal(exc):
187
+ return False
104
188
  return exc.response.status_code >= 500 or exc.response.status_code == 429
105
189
 
106
190
  return False
@@ -123,7 +207,7 @@ class TransportRetryPolicy:
123
207
  Raises the last exception if all retries are exhausted.
124
208
  """
125
209
  attempt = 0
126
- storage_attempt = 0
210
+ worker_pause_attempt = 0
127
211
  last_exc: Exception | None = None
128
212
 
129
213
  while attempt < self.max_attempts:
@@ -134,20 +218,30 @@ class TransportRetryPolicy:
134
218
  last_exc = exc
135
219
  stop = _worker_storage_admission_stop.get()
136
220
  refusal = _storage_refusal(exc) if stop is not None else None
137
- if refusal is not None and stop is not None:
221
+ backend_refusal, backend_delay = _backend_unavailable_refusal(exc)
222
+ if backend_refusal and backend_delay is None:
223
+ raise
224
+ pause: tuple[str, int] | None = None
225
+ if refusal is not None:
138
226
  error, poll_id = refusal
139
- if not error.is_storage_admission_failure(poll_id) or stop():
227
+ if not error.is_storage_admission_failure(poll_id):
140
228
  raise
141
- storage_attempt += 1
142
229
  assert isinstance(error.body, dict)
230
+ pause = ("storage admission paused", error.body["retry_after_seconds"])
231
+ elif backend_delay is not None:
232
+ pause = ("worker backend unavailable", backend_delay)
233
+ if pause is not None and stop is not None:
234
+ if stop():
235
+ raise
236
+ worker_pause_attempt += 1
143
237
  delay = min(
144
238
  5.0,
145
239
  max(
146
- self.backoff_seconds(min(storage_attempt - 1, 6)),
147
- error.body["retry_after_seconds"],
240
+ self.backoff_seconds(min(worker_pause_attempt - 1, 6)),
241
+ pause[1],
148
242
  ),
149
243
  )
150
- log.warning("storage admission paused; retrying the same worker request in %.2fs", delay)
244
+ log.warning("%s; retrying the same worker request in %.2fs", pause[0], delay)
151
245
  # Do not consume the finite transport budget or repeat serialization/uploads.
152
246
  while delay > 0:
153
247
  if stop():
@@ -190,6 +190,12 @@ def _poll_error_summary(error: BaseException) -> str:
190
190
  return f"server returned {error.status} reason={reason[:64]!r}"
191
191
 
192
192
 
193
+ def _poll_capacity_delay(error: BaseException, task_kind: str, task_queue: str) -> int | None:
194
+ if not isinstance(error, ServerError):
195
+ return None
196
+ return error.poll_capacity_backpressure_delay(task_kind, task_queue)
197
+
198
+
193
199
  def _command_payload_codec(codec: object) -> str:
194
200
  if codec is None:
195
201
  return serializer.AVRO_CODEC
@@ -2539,6 +2545,12 @@ class Worker:
2539
2545
  return
2540
2546
  if _is_storage_admission_error(e):
2541
2547
  raise
2548
+ delay = _poll_capacity_delay(e, "workflow_task", self.task_queue)
2549
+ if delay is not None:
2550
+ self._record_poll_metrics("workflow", "backpressure", time.perf_counter() - poll_start)
2551
+ log.debug("workflow poll capacity backpressure; retrying in %d s", delay)
2552
+ await asyncio.sleep(delay)
2553
+ continue
2542
2554
  self._record_poll_metrics("workflow", "error", time.perf_counter() - poll_start)
2543
2555
  log.warning("workflow poll error: %s", _poll_error_summary(e))
2544
2556
  await asyncio.sleep(1.0)
@@ -2637,6 +2649,12 @@ class Worker:
2637
2649
  return
2638
2650
  if _is_storage_admission_error(e):
2639
2651
  raise
2652
+ delay = _poll_capacity_delay(e, "activity_task", self.task_queue)
2653
+ if delay is not None:
2654
+ self._record_poll_metrics("activity", "backpressure", time.perf_counter() - poll_start)
2655
+ log.debug("activity poll capacity backpressure; retrying in %d s", delay)
2656
+ await asyncio.sleep(delay)
2657
+ continue
2640
2658
  self._record_poll_metrics("activity", "error", time.perf_counter() - poll_start)
2641
2659
  log.warning("activity poll error: %s", _poll_error_summary(e))
2642
2660
  await asyncio.sleep(1.0)
@@ -2688,6 +2706,12 @@ class Worker:
2688
2706
  return
2689
2707
  if _is_storage_admission_error(e):
2690
2708
  raise
2709
+ delay = _poll_capacity_delay(e, "query_task", self.task_queue)
2710
+ if delay is not None:
2711
+ self._record_poll_metrics("query", "backpressure", time.perf_counter() - poll_start)
2712
+ log.debug("query poll capacity backpressure; retrying in %d s", delay)
2713
+ await asyncio.sleep(delay)
2714
+ continue
2691
2715
  self._record_poll_metrics("query", "error", time.perf_counter() - poll_start)
2692
2716
  log.warning("query poll error: %s", _poll_error_summary(e))
2693
2717
  await asyncio.sleep(1.0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.3.0
3
+ Version: 2.3.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
@@ -2682,6 +2682,34 @@ class TestFailWorkflowTask:
2682
2682
  assert isinstance(request_body["poll_request_id"], str)
2683
2683
  assert request_body["poll_request_id"] != ""
2684
2684
 
2685
+ @pytest.mark.asyncio
2686
+ async def test_typed_poll_capacity_refusal_reaches_worker_after_one_http_request(
2687
+ self, client: Client,
2688
+ ) -> None:
2689
+ requests: list[httpx.Request] = []
2690
+
2691
+ async def refused(method: str, path: str, **kwargs: object) -> httpx.Response:
2692
+ request = httpx.Request(
2693
+ method, "http://localhost:8080" + path,
2694
+ headers=kwargs["headers"], json=kwargs["json"],
2695
+ )
2696
+ requests.append(request)
2697
+ return httpx.Response(429, request=request, json={
2698
+ "task": None,
2699
+ "poll_status": "long_poll_capacity_exhausted",
2700
+ "reason": "long_poll_capacity_exhausted",
2701
+ "task_kind": "workflow_task",
2702
+ "task_queue": "q1",
2703
+ "retryable": True,
2704
+ "retry_after_seconds": 2,
2705
+ })
2706
+
2707
+ with patch.object(client._http, "request", side_effect=refused), pytest.raises(ServerError) as refusal:
2708
+ await client.poll_workflow_task(worker_id="worker-1", task_queue="q1")
2709
+
2710
+ assert len(requests) == 1
2711
+ assert refusal.value.poll_capacity_backpressure_delay("workflow_task", "q1") == 2
2712
+
2685
2713
  @pytest.mark.asyncio
2686
2714
  async def test_poll_workflow_task_response_preserves_no_compatible_status(self, client: Client) -> None:
2687
2715
  response = {
@@ -44,7 +44,7 @@ def test_worker_release_identity_matches_supported_server_and_protocol() -> None
44
44
  assert check_release_metadata.VERSION_PATTERN.fullmatch(project["version"]) is not None
45
45
  assert release["product-train"] == project["version"]
46
46
  assert release["registry-version"] == project["version"].replace("-rc.", "rc")
47
- assert release["supported-server-versions"] == "2.4.2"
47
+ assert check_release_metadata.VERSION_PATTERN.fullmatch(release["supported-server-versions"]) is not None
48
48
  assert release["worker-protocol-version"] == PROTOCOL_VERSION == "1.19"
49
49
  assert release["durable-selection"] is True
50
50
  assert release["durable-selection-minimum-worker-protocol-version"] == "1.19"
@@ -40,6 +40,60 @@ class TestRetryPolicy:
40
40
  exc = httpx.HTTPStatusError("rate limited", request=response.request, response=response)
41
41
  assert policy.should_retry(exc, attempt=0) is True
42
42
 
43
+ @pytest.mark.parametrize(
44
+ ("path", "task_kind"),
45
+ [
46
+ ("/api/worker/workflow-tasks/poll", "workflow_task"),
47
+ ("/api/runtime/v1/namespaces/acme/api/worker/workflow-tasks/poll", "workflow_task"),
48
+ ("/api/worker/activity-tasks/poll", "activity_task"),
49
+ ("/api/worker/query-tasks/poll", "query_task"),
50
+ ],
51
+ )
52
+ @pytest.mark.asyncio
53
+ async def test_typed_poll_capacity_refusal_does_not_repeat_transport_request(
54
+ self, path: str, task_kind: str,
55
+ ) -> None:
56
+ policy = RetryPolicy(max_attempts=3, jitter=False)
57
+ request = httpx.Request(
58
+ "POST", "http://test" + path,
59
+ headers={"X-Durable-Workflow-Protocol-Version": "1.19"},
60
+ json={"task_queue": "q1", "worker_id": "worker-1"},
61
+ )
62
+ response = httpx.Response(429, request=request, json={
63
+ "task": None,
64
+ "poll_status": "long_poll_capacity_exhausted",
65
+ "reason": "long_poll_capacity_exhausted",
66
+ "task_kind": task_kind,
67
+ "task_queue": "q1",
68
+ "retryable": True,
69
+ "retry_after_seconds": 2,
70
+ })
71
+ error = httpx.HTTPStatusError("capacity", request=request, response=response)
72
+ calls = 0
73
+
74
+ async def refused() -> None:
75
+ nonlocal calls
76
+ calls += 1
77
+ raise error
78
+
79
+ with pytest.raises(httpx.HTTPStatusError):
80
+ await policy.execute(refused)
81
+ assert calls == 1
82
+
83
+ wrong_kind = httpx.Response(429, request=request, json={
84
+ **response.json(), "task_kind": task_kind.removesuffix("_task"),
85
+ })
86
+ assert policy.should_retry(
87
+ httpx.HTTPStatusError("wrong kind", request=request, response=wrong_kind), attempt=0,
88
+ ) is True
89
+
90
+ malformed = httpx.Response(429, request=request, json={
91
+ "reason": "long_poll_capacity_exhausted", "retry_after_seconds": 2,
92
+ })
93
+ assert policy.should_retry(
94
+ httpx.HTTPStatusError("malformed", request=request, response=malformed), attempt=0,
95
+ ) is True
96
+
43
97
  def test_should_not_retry_4xx_client_error(self) -> None:
44
98
  policy = RetryPolicy(max_attempts=3)
45
99
  response = httpx.Response(status_code=404, request=httpx.Request("GET", "http://test"))
@@ -39,6 +39,30 @@ def pressure(poll_id: str | None = None, *, reason: str = "storage_pressure", en
39
39
  return body
40
40
 
41
41
 
42
+ def backend_unavailable(request: httpx.Request) -> dict[str, Any]:
43
+ submitted = json.loads(request.content)
44
+ path = request.url.path
45
+ operation = {
46
+ "/api/worker/workflow-tasks/poll": "poll_workflow_task",
47
+ "/api/worker/activity-tasks/poll": "poll_activity_task",
48
+ "/api/worker/query-tasks/poll": "poll_query_task",
49
+ "/api/worker/register": "register_worker",
50
+ "/api/worker/heartbeat": "heartbeat_worker",
51
+ }[path]
52
+ body: dict[str, Any] = {
53
+ "reason": "backend_unavailable", "operation": operation, "outcome": "unknown",
54
+ "worker_id": submitted["worker_id"], "task_queue": submitted.get("task_queue"),
55
+ "retryable": True, "retry_after_seconds": 1,
56
+ }
57
+ if path.endswith("/poll"):
58
+ body.update({
59
+ "task": None, "poll_status": "backend_unavailable",
60
+ "poll_request_id": submitted["poll_request_id"],
61
+ "retry_same_poll_request_id": True,
62
+ })
63
+ return body
64
+
65
+
42
66
  @contextmanager
43
67
  def worker_scope(stop: Callable[[], bool] = lambda: False) -> Iterator[None]:
44
68
  token = _worker_storage_admission_stop.set(stop)
@@ -104,6 +128,161 @@ async def test_poll_keeps_identity_after_ambiguous_response_and_pressure(
104
128
  assert sum(retry_sleeps) == pytest.approx(5)
105
129
 
106
130
 
131
+ @pytest.mark.parametrize("kind", ["workflow", "activity", "query", "multiplexed"])
132
+ async def test_backend_outage_preserves_logical_poll_after_transport_budget(
133
+ kind: str, retry_sleeps: list[float],
134
+ ) -> None:
135
+ requests: list[bytes] = []
136
+
137
+ def handler(request: httpx.Request) -> httpx.Response:
138
+ requests.append(request.content)
139
+ if len(requests) == 1:
140
+ raise httpx.ReadTimeout("Response lost after a possible claim.", request=request)
141
+ if len(requests) <= 5:
142
+ return httpx.Response(503, json=backend_unavailable(request))
143
+ return httpx.Response(200, json={"task": {"task_id": "reconciled-claim"}})
144
+
145
+ async with client_for(handler) as client:
146
+ with worker_scope():
147
+ if kind == "multiplexed":
148
+ task = await client.poll_workflow_task(
149
+ worker_id="backend-worker", task_queue="orders",
150
+ task_kinds=("workflow", "update_validation"),
151
+ )
152
+ else:
153
+ task = await getattr(client, f"poll_{kind}_task")(
154
+ worker_id="backend-worker", task_queue="orders",
155
+ )
156
+ assert task == {"task_id": "reconciled-claim"}
157
+ assert len(requests) == 6
158
+ assert len(set(requests)) == 1
159
+ assert sum(retry_sleeps) == pytest.approx(4)
160
+
161
+
162
+ @pytest.mark.parametrize("method,kwargs", [
163
+ ("register_worker", {
164
+ "worker_id": "backend-worker", "task_queue": "orders",
165
+ "capability_manifest": PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST,
166
+ }),
167
+ ("heartbeat_worker", {"worker_id": "backend-worker"}),
168
+ ])
169
+ async def test_backend_outage_retries_worker_registration_and_heartbeat(
170
+ method: str, kwargs: dict[str, Any], retry_sleeps: list[float],
171
+ ) -> None:
172
+ requests: list[bytes] = []
173
+
174
+ def handler(request: httpx.Request) -> httpx.Response:
175
+ requests.append(request.content)
176
+ if len(requests) <= 4:
177
+ return httpx.Response(503, json=backend_unavailable(request))
178
+ return httpx.Response(200, json={"recorded": True})
179
+
180
+ async with client_for(handler) as client:
181
+ with worker_scope():
182
+ result = await getattr(client, method)(**kwargs)
183
+ assert result == {"recorded": True}
184
+ assert len(requests) == 5
185
+ assert len(set(requests)) == 1
186
+ assert sum(retry_sleeps) == pytest.approx(4)
187
+
188
+
189
+ @pytest.mark.parametrize("override", [
190
+ {"operation": "poll_activity_task"},
191
+ {"outcome": "failed"}, {"worker_id": "other-worker"},
192
+ {"task_queue": "other-queue"}, {"retryable": False},
193
+ {"retry_after_seconds": 0}, {"retry_after_seconds": True},
194
+ {"retry_after_seconds": "1"}, {"task": {"task_id": "claimed"}},
195
+ {"poll_status": "empty"}, {"poll_request_id": "another-poll"},
196
+ {"retry_same_poll_request_id": False},
197
+ ])
198
+ async def test_invalid_backend_outage_contract_remains_bounded(
199
+ override: dict[str, Any], retry_sleeps: list[float],
200
+ ) -> None:
201
+ calls = 0
202
+
203
+ def handler(request: httpx.Request) -> httpx.Response:
204
+ nonlocal calls
205
+ calls += 1
206
+ return httpx.Response(503, json={**backend_unavailable(request), **override})
207
+
208
+ async with client_for(handler) as client:
209
+ with worker_scope(), pytest.raises(ServerError):
210
+ await client.poll_workflow_task(worker_id="backend-worker", task_queue="orders")
211
+ assert calls == 1
212
+ assert not retry_sleeps
213
+
214
+
215
+ async def test_legacy_validation_backend_refusal_does_not_repeat_ambiguous_poll(
216
+ retry_sleeps: list[float],
217
+ ) -> None:
218
+ calls = 0
219
+
220
+ def handler(request: httpx.Request) -> httpx.Response:
221
+ nonlocal calls
222
+ calls += 1
223
+ return httpx.Response(503, json={
224
+ "reason": "backend_unavailable", "operation": "poll_update_validation_task",
225
+ "outcome": "unknown", "worker_id": "backend-worker", "task_queue": "orders",
226
+ "retryable": False, "retry_after_seconds": 1, "task": None,
227
+ "poll_status": "backend_unavailable", "poll_request_id": None,
228
+ "retry_same_poll_request_id": True,
229
+ })
230
+
231
+ async with client_for(handler) as client:
232
+ with worker_scope(), pytest.raises(ServerError):
233
+ await client.poll_update_validation_task(worker_id="backend-worker", task_queue="orders")
234
+ assert calls == 1
235
+ assert not retry_sleeps
236
+
237
+
238
+ async def test_direct_backend_outage_poll_remains_bounded(retry_sleeps: list[float]) -> None:
239
+ calls = 0
240
+
241
+ def handler(request: httpx.Request) -> httpx.Response:
242
+ nonlocal calls
243
+ calls += 1
244
+ return httpx.Response(503, json=backend_unavailable(request))
245
+
246
+ async with client_for(handler) as client:
247
+ with pytest.raises(ServerError):
248
+ await client.poll_workflow_task(worker_id="backend-worker", task_queue="orders")
249
+ assert calls == 2
250
+
251
+
252
+ async def test_control_backend_error_keeps_finite_transport_retries(retry_sleeps: list[float]) -> None:
253
+ calls = 0
254
+
255
+ def handler(request: httpx.Request) -> httpx.Response:
256
+ nonlocal calls
257
+ calls += 1
258
+ return httpx.Response(503, json={"reason": "backend_unavailable", "retryable": True})
259
+
260
+ async with client_for(handler) as client:
261
+ with worker_scope(), pytest.raises(ServerError):
262
+ await client._request("POST", "/workflows", json={})
263
+ assert calls == 2
264
+
265
+
266
+ async def test_shutdown_interrupts_backend_outage_without_new_poll(monkeypatch: pytest.MonkeyPatch) -> None:
267
+ calls = 0
268
+ stopped = False
269
+
270
+ def handler(request: httpx.Request) -> httpx.Response:
271
+ nonlocal calls
272
+ calls += 1
273
+ return httpx.Response(503, json=backend_unavailable(request))
274
+
275
+ async def stop_on_sleep(delay: float) -> None:
276
+ nonlocal stopped
277
+ stopped = True
278
+
279
+ monkeypatch.setattr(retry_module, "asyncio", SimpleNamespace(sleep=stop_on_sleep))
280
+ async with client_for(handler) as client:
281
+ with worker_scope(lambda: stopped), pytest.raises(ServerError):
282
+ await client.poll_workflow_task(worker_id="backend-worker", task_queue="orders")
283
+ assert calls == 1
284
+
285
+
107
286
  @pytest.mark.parametrize("method,kwargs", [
108
287
  ("register_worker", {
109
288
  "worker_id": "storage-worker", "task_queue": "orders",
@@ -867,7 +867,60 @@ class TestWorkerRegistration:
867
867
  ],
868
868
  )
869
869
  @pytest.mark.asyncio
870
- async def test_poll_warning_omits_large_server_response(
870
+ async def test_typed_poll_backpressure_waits_without_warning(
871
+ self,
872
+ mock_client: AsyncMock,
873
+ monkeypatch: pytest.MonkeyPatch,
874
+ caplog: pytest.LogCaptureFixture,
875
+ poll_method: str,
876
+ loop_method: str,
877
+ task_kind: str,
878
+ ) -> None:
879
+ worker = Worker(
880
+ mock_client,
881
+ task_queue="q1",
882
+ workflows=[TestWorkflow],
883
+ activities=[echo_activity],
884
+ )
885
+ getattr(mock_client, poll_method).side_effect = ServerError(
886
+ 429,
887
+ {
888
+ "task": None,
889
+ "poll_status": "long_poll_capacity_exhausted",
890
+ "reason": "long_poll_capacity_exhausted",
891
+ "task_kind": f"{task_kind}_task",
892
+ "task_queue": "q1",
893
+ "retryable": True,
894
+ "retry_after_seconds": 2,
895
+ },
896
+ )
897
+ outcomes: list[tuple[str, str]] = []
898
+ monkeypatch.setattr(
899
+ worker, "_record_poll_metrics",
900
+ lambda kind, outcome, _duration: outcomes.append((kind, outcome)),
901
+ )
902
+
903
+ async def stop_after_backoff(seconds: float) -> None:
904
+ assert seconds == 2
905
+ worker._stop.set()
906
+
907
+ monkeypatch.setattr(worker_module.asyncio, "sleep", stop_after_backoff)
908
+ with caplog.at_level(logging.WARNING, logger="durable_workflow.worker"):
909
+ await getattr(worker, loop_method)()
910
+
911
+ assert outcomes == [(task_kind, "backpressure")]
912
+ assert not [record for record in caplog.records if record.name == "durable_workflow.worker"]
913
+
914
+ @pytest.mark.parametrize(
915
+ ("poll_method", "loop_method", "task_kind"),
916
+ [
917
+ ("poll_workflow_task", "_poll_workflow_tasks", "workflow"),
918
+ ("poll_activity_task", "_poll_activity_tasks", "activity"),
919
+ ("poll_query_task", "_poll_query_tasks", "query"),
920
+ ],
921
+ )
922
+ @pytest.mark.asyncio
923
+ async def test_malformed_poll_warning_omits_large_server_response(
871
924
  self,
872
925
  mock_client: AsyncMock,
873
926
  monkeypatch: pytest.MonkeyPatch,