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.
- {durable_workflow-2.3.0/src/durable_workflow.egg-info → durable_workflow-2.3.1}/PKG-INFO +1 -1
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/pyproject.toml +4 -4
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/retry_policy.py +73 -7
- {durable_workflow-2.3.0 → durable_workflow-2.3.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_release_metadata.py +1 -1
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_storage_admission.py +179 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/LICENSE +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/README.md +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/setup.cfg +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/client.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/errors.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_client.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_errors.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_invocable.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_metrics.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_queries.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_schedules.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_serializer.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_signals.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_sleep.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_sync.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_updates.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_worker.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.3.0 → durable_workflow-2.3.1}/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.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.
|
|
75
|
-
registry-version = "2.3.
|
|
76
|
-
supported-server-versions = "2.4.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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(
|
|
147
|
-
|
|
212
|
+
self.backoff_seconds(min(worker_pause_attempt - 1, 6)),
|
|
213
|
+
pause[1],
|
|
148
214
|
),
|
|
149
215
|
)
|
|
150
|
-
log.warning("
|
|
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():
|
|
@@ -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"]
|
|
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",
|
|
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.0 → durable_workflow-2.3.1}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/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.0 → durable_workflow-2.3.1}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/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
|
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/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
|
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/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.0 → durable_workflow-2.3.1}/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.0 → durable_workflow-2.3.1}/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.0 → durable_workflow-2.3.1}/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
|
|
File without changes
|
{durable_workflow-2.3.0 → durable_workflow-2.3.1}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|