durable-workflow 2.3.1__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.
- {durable_workflow-2.3.1/src/durable_workflow.egg-info → durable_workflow-2.3.4}/PKG-INFO +1 -1
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/pyproject.toml +3 -3
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/errors.py +19 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/retry_policy.py +58 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/worker.py +24 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_client.py +28 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_retry_policy.py +54 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_storage_admission.py +63 -4
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_worker.py +54 -1
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/LICENSE +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/README.md +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/setup.cfg +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/client.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_errors.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_invocable.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_metrics.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_queries.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_replay.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_schedules.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_serializer.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_signals.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_sleep.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_sync.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_updates.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.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.
|
|
75
|
-
registry-version = "2.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"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -121,6 +151,32 @@ def _backend_unavailable_refusal(exc: Exception) -> tuple[bool, int | None]:
|
|
|
121
151
|
return True, delay
|
|
122
152
|
|
|
123
153
|
|
|
154
|
+
def _poll_capacity_refusal(exc: Exception) -> bool:
|
|
155
|
+
if not isinstance(exc, httpx.HTTPStatusError) or exc.response.status_code != 429:
|
|
156
|
+
return False
|
|
157
|
+
request = exc.request
|
|
158
|
+
if request.method != "POST" or "X-Durable-Workflow-Protocol-Version" not in request.headers:
|
|
159
|
+
return False
|
|
160
|
+
task_kinds = {
|
|
161
|
+
"/api/worker/workflow-tasks/poll": "workflow_task",
|
|
162
|
+
"/api/worker/activity-tasks/poll": "activity_task",
|
|
163
|
+
"/api/worker/query-tasks/poll": "query_task",
|
|
164
|
+
}
|
|
165
|
+
task_kind = next((kind for path, kind in task_kinds.items() if request.url.path.endswith(path)), None)
|
|
166
|
+
if task_kind is None:
|
|
167
|
+
return False
|
|
168
|
+
try:
|
|
169
|
+
body = exc.response.json()
|
|
170
|
+
submitted = json.loads(request.content)
|
|
171
|
+
except ValueError:
|
|
172
|
+
return False
|
|
173
|
+
if not isinstance(submitted, dict) or not isinstance(submitted.get("task_queue"), str):
|
|
174
|
+
return False
|
|
175
|
+
return ServerError(429, body).poll_capacity_backpressure_delay(
|
|
176
|
+
task_kind, submitted["task_queue"],
|
|
177
|
+
) is not None
|
|
178
|
+
|
|
179
|
+
|
|
124
180
|
@dataclass
|
|
125
181
|
class TransportRetryPolicy:
|
|
126
182
|
"""
|
|
@@ -157,6 +213,8 @@ class TransportRetryPolicy:
|
|
|
157
213
|
|
|
158
214
|
# Retry 5xx server errors and 429 rate limit
|
|
159
215
|
if isinstance(exc, httpx.HTTPStatusError):
|
|
216
|
+
if _poll_capacity_refusal(exc):
|
|
217
|
+
return False
|
|
160
218
|
return exc.response.status_code >= 500 or exc.response.status_code == 429
|
|
161
219
|
|
|
162
220
|
return False
|
|
@@ -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)
|
|
@@ -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 = {
|
|
@@ -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"))
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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"},
|
|
@@ -867,7 +867,60 @@ class TestWorkerRegistration:
|
|
|
867
867
|
],
|
|
868
868
|
)
|
|
869
869
|
@pytest.mark.asyncio
|
|
870
|
-
async def
|
|
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,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow/replay_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_runtime_external_payload_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.3.1 → durable_workflow-2.3.4}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|