durable-workflow 2.3.1__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.1/src/durable_workflow.egg-info → durable_workflow-2.3.3}/PKG-INFO +1 -1
  2. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/pyproject.toml +3 -3
  3. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/errors.py +19 -0
  4. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/retry_policy.py +28 -0
  5. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/worker.py +24 -0
  6. {durable_workflow-2.3.1 → durable_workflow-2.3.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_client.py +28 -0
  8. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_retry_policy.py +54 -0
  9. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_worker.py +54 -1
  10. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/LICENSE +0 -0
  11. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/README.md +0 -0
  12. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/setup.cfg +0 -0
  13. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/__init__.py +0 -0
  14. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/_avro.py +0 -0
  15. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/activity.py +0 -0
  16. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/auth_composition.py +0 -0
  17. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/client.py +0 -0
  18. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/external_storage.py +0 -0
  19. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/external_task_input.py +0 -0
  20. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/external_task_result.py +0 -0
  21. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/history_bundle_verify.py +0 -0
  22. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/interceptors.py +0 -0
  23. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/invocable.py +0 -0
  24. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/metrics.py +0 -0
  25. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/nexus.py +0 -0
  26. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/py.typed +0 -0
  27. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/python_conformance.py +0 -0
  28. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/replay_conformance.py +0 -0
  29. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/replay_verify.py +0 -0
  30. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/serializer.py +0 -0
  31. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/sync.py +0 -0
  32. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/testing.py +0 -0
  33. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/worker_session.py +0 -0
  34. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/workflow.py +0 -0
  35. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  36. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  37. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  38. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  39. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/requires.txt +0 -0
  40. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
  41. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_activity_context.py +0 -0
  42. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_auth_composition.py +0 -0
  43. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_avro_value_benchmark.py +0 -0
  44. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_avro_value_protocol.py +0 -0
  45. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_ci_checkout.py +0 -0
  46. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_ci_integration_endpoint.py +0 -0
  47. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_codec_regression_corpus.py +0 -0
  48. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_control_plane_parity_fixtures.py +0 -0
  49. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_docstring_cross_references.py +0 -0
  50. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_durable_selection.py +0 -0
  51. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_errors.py +0 -0
  52. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_external_storage.py +0 -0
  53. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_external_task_input.py +0 -0
  54. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_external_task_result.py +0 -0
  55. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_golden_history_replay.py +0 -0
  56. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_history_bundle_verify.py +0 -0
  57. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_history_event_contract.py +0 -0
  58. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_invocable.py +0 -0
  59. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_message_streams.py +0 -0
  60. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_metrics.py +0 -0
  61. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_order_processing_example.py +0 -0
  62. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_parallel_completion_order_replay.py +0 -0
  63. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_parallel_saga_authoring.py +0 -0
  64. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_public_boundary_scanner.py +0 -0
  65. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_pypi_project_surface.py +0 -0
  66. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_python_conformance.py +0 -0
  67. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_queries.py +0 -0
  68. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_readme_quickstart.py +0 -0
  69. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_redrive_replay.py +0 -0
  70. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_release_metadata.py +0 -0
  71. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_replay.py +0 -0
  72. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_replay_conformance.py +0 -0
  73. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_replay_regression_corpus.py +0 -0
  74. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_replay_verify.py +0 -0
  75. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_runtime_external_payload_transport.py +0 -0
  76. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_schedules.py +0 -0
  77. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_serializer.py +0 -0
  78. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_signals.py +0 -0
  79. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_sleep.py +0 -0
  80. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_standalone_activity_client.py +0 -0
  81. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_storage_admission.py +0 -0
  82. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_sync.py +0 -0
  83. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_testing_harness.py +0 -0
  84. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_update_signal_condition_replay.py +0 -0
  85. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_updates.py +0 -0
  86. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_wait_condition.py +0 -0
  87. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_worker_session.py +0 -0
  88. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_workflow_result_timeout.py +0 -0
  89. {durable_workflow-2.3.1 → durable_workflow-2.3.3}/tests/test_workflow_streams.py +0 -0
  90. {durable_workflow-2.3.1 → 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.1
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.1"
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,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.1"
75
- registry-version = "2.3.1"
74
+ product-train = "2.3.3"
75
+ registry-version = "2.3.3"
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
@@ -121,6 +121,32 @@ def _backend_unavailable_refusal(exc: Exception) -> tuple[bool, int | None]:
121
121
  return True, delay
122
122
 
123
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
+
124
150
  @dataclass
125
151
  class TransportRetryPolicy:
126
152
  """
@@ -157,6 +183,8 @@ class TransportRetryPolicy:
157
183
 
158
184
  # Retry 5xx server errors and 429 rate limit
159
185
  if isinstance(exc, httpx.HTTPStatusError):
186
+ if _poll_capacity_refusal(exc):
187
+ return False
160
188
  return exc.response.status_code >= 500 or exc.response.status_code == 429
161
189
 
162
190
  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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.3.1
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 = {
@@ -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"))
@@ -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,