durable-workflow 2.3.0__tar.gz → 2.3.1__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.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/pyproject.toml +4 -4
  3. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/retry_policy.py +73 -7
  4. {durable_workflow-2.3.0 → durable_workflow-2.3.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  5. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_release_metadata.py +1 -1
  6. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_storage_admission.py +179 -0
  7. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/LICENSE +0 -0
  8. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/README.md +0 -0
  9. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/setup.cfg +0 -0
  10. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/__init__.py +0 -0
  11. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/_avro.py +0 -0
  12. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/activity.py +0 -0
  13. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/auth_composition.py +0 -0
  14. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/client.py +0 -0
  15. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/errors.py +0 -0
  16. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/external_storage.py +0 -0
  17. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/external_task_input.py +0 -0
  18. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/external_task_result.py +0 -0
  19. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  20. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/interceptors.py +0 -0
  21. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/invocable.py +0 -0
  22. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/metrics.py +0 -0
  23. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/nexus.py +0 -0
  24. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/py.typed +0 -0
  25. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/python_conformance.py +0 -0
  26. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/replay_conformance.py +0 -0
  27. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/replay_verify.py +0 -0
  28. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/serializer.py +0 -0
  29. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/sync.py +0 -0
  30. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/testing.py +0 -0
  31. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/worker.py +0 -0
  32. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/worker_session.py +0 -0
  33. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/workflow.py +0 -0
  34. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  35. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  36. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  37. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  38. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  39. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  40. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_activity_context.py +0 -0
  41. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_auth_composition.py +0 -0
  42. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_avro_value_benchmark.py +0 -0
  43. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_avro_value_protocol.py +0 -0
  44. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_ci_checkout.py +0 -0
  45. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_ci_integration_endpoint.py +0 -0
  46. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_client.py +0 -0
  47. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_codec_regression_corpus.py +0 -0
  48. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  49. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_docstring_cross_references.py +0 -0
  50. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_durable_selection.py +0 -0
  51. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_errors.py +0 -0
  52. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_external_storage.py +0 -0
  53. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_external_task_input.py +0 -0
  54. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_external_task_result.py +0 -0
  55. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_golden_history_replay.py +0 -0
  56. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_history_bundle_verify.py +0 -0
  57. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_history_event_contract.py +0 -0
  58. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_invocable.py +0 -0
  59. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_message_streams.py +0 -0
  60. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_metrics.py +0 -0
  61. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_order_processing_example.py +0 -0
  62. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_parallel_completion_order_replay.py +0 -0
  63. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_parallel_saga_authoring.py +0 -0
  64. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_public_boundary_scanner.py +0 -0
  65. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_pypi_project_surface.py +0 -0
  66. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_python_conformance.py +0 -0
  67. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_queries.py +0 -0
  68. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_readme_quickstart.py +0 -0
  69. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_redrive_replay.py +0 -0
  70. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay.py +0 -0
  71. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay_conformance.py +0 -0
  72. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay_regression_corpus.py +0 -0
  73. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay_verify.py +0 -0
  74. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_retry_policy.py +0 -0
  75. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_runtime_external_payload_transport.py +0 -0
  76. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_schedules.py +0 -0
  77. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_serializer.py +0 -0
  78. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_signals.py +0 -0
  79. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_sleep.py +0 -0
  80. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_standalone_activity_client.py +0 -0
  81. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_sync.py +0 -0
  82. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_testing_harness.py +0 -0
  83. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_update_signal_condition_replay.py +0 -0
  84. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_updates.py +0 -0
  85. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_wait_condition.py +0 -0
  86. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_worker.py +0 -0
  87. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_worker_session.py +0 -0
  88. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_workflow_result_timeout.py +0 -0
  89. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_workflow_streams.py +0 -0
  90. {durable_workflow-2.3.0 → durable_workflow-2.3.1}/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.1
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.1"
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.1"
75
+ registry-version = "2.3.1"
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
@@ -65,6 +65,62 @@ 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
+
68
124
  @dataclass
69
125
  class TransportRetryPolicy:
70
126
  """
@@ -123,7 +179,7 @@ class TransportRetryPolicy:
123
179
  Raises the last exception if all retries are exhausted.
124
180
  """
125
181
  attempt = 0
126
- storage_attempt = 0
182
+ worker_pause_attempt = 0
127
183
  last_exc: Exception | None = None
128
184
 
129
185
  while attempt < self.max_attempts:
@@ -134,20 +190,30 @@ class TransportRetryPolicy:
134
190
  last_exc = exc
135
191
  stop = _worker_storage_admission_stop.get()
136
192
  refusal = _storage_refusal(exc) if stop is not None else None
137
- if refusal is not None and stop is not None:
193
+ backend_refusal, backend_delay = _backend_unavailable_refusal(exc)
194
+ if backend_refusal and backend_delay is None:
195
+ raise
196
+ pause: tuple[str, int] | None = None
197
+ if refusal is not None:
138
198
  error, poll_id = refusal
139
- if not error.is_storage_admission_failure(poll_id) or stop():
199
+ if not error.is_storage_admission_failure(poll_id):
140
200
  raise
141
- storage_attempt += 1
142
201
  assert isinstance(error.body, dict)
202
+ pause = ("storage admission paused", error.body["retry_after_seconds"])
203
+ elif backend_delay is not None:
204
+ pause = ("worker backend unavailable", backend_delay)
205
+ if pause is not None and stop is not None:
206
+ if stop():
207
+ raise
208
+ worker_pause_attempt += 1
143
209
  delay = min(
144
210
  5.0,
145
211
  max(
146
- self.backoff_seconds(min(storage_attempt - 1, 6)),
147
- error.body["retry_after_seconds"],
212
+ self.backoff_seconds(min(worker_pause_attempt - 1, 6)),
213
+ pause[1],
148
214
  ),
149
215
  )
150
- log.warning("storage admission paused; retrying the same worker request in %.2fs", delay)
216
+ log.warning("%s; retrying the same worker request in %.2fs", pause[0], delay)
151
217
  # Do not consume the finite transport budget or repeat serialization/uploads.
152
218
  while delay > 0:
153
219
  if stop():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.3.0
3
+ Version: 2.3.1
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
@@ -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"
@@ -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",