durable-workflow 2.11.0__tar.gz → 2.11.2__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 (166) hide show
  1. {durable_workflow-2.11.0/src/durable_workflow.egg-info → durable_workflow-2.11.2}/PKG-INFO +1 -1
  2. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/pyproject.toml +3 -3
  3. durable_workflow-2.11.2/src/durable_workflow/_poll_transport.py +55 -0
  4. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/client.py +28 -4
  5. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/worker.py +67 -9
  6. {durable_workflow-2.11.0 → durable_workflow-2.11.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  8. durable_workflow-2.11.2/tests/test_poll_transport.py +255 -0
  9. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_worker.py +228 -0
  10. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/LICENSE +0 -0
  11. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/README.md +0 -0
  12. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/setup.cfg +0 -0
  13. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/__init__.py +0 -0
  14. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_activity_lease.py +0 -0
  15. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_activity_process.py +0 -0
  16. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_cancellation_scope.py +0 -0
  18. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  19. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  20. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_failure_reporting.py +0 -0
  21. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_metric_routes.py +0 -0
  22. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_prepared_local_activity.py +0 -0
  23. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_query_task_authority.py +0 -0
  24. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  25. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/activity.py +0 -0
  26. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/auth_composition.py +0 -0
  27. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/cancellation.py +0 -0
  28. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/errors.py +0 -0
  29. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/external_storage.py +0 -0
  30. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/external_task_input.py +0 -0
  31. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/external_task_result.py +0 -0
  32. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/history_bundle_verify.py +0 -0
  33. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/interceptors.py +0 -0
  34. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/invocable.py +0 -0
  35. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/metrics.py +0 -0
  36. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/nexus.py +0 -0
  37. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/py.typed +0 -0
  38. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/python_conformance.py +0 -0
  39. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/replay_conformance.py +0 -0
  40. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/replay_verify.py +0 -0
  41. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/retry_policy.py +0 -0
  42. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/serializer.py +0 -0
  43. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/sync.py +0 -0
  44. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/telemetry.py +0 -0
  45. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/telemetry_carrier.py +0 -0
  46. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/testing.py +0 -0
  47. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/worker_session.py +0 -0
  48. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/workflow.py +0 -0
  49. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  50. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  51. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  52. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow.egg-info/requires.txt +0 -0
  53. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
  54. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_activity_cancellation_policies.py +0 -0
  55. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_activity_context.py +0 -0
  56. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_activity_heartbeat_authority.py +0 -0
  57. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_activity_lease_authority.py +0 -0
  58. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_activity_lease_renewal.py +0 -0
  59. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_activity_lease_worker.py +0 -0
  60. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_activity_process.py +0 -0
  61. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_auth_composition.py +0 -0
  62. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_avro_value_benchmark.py +0 -0
  63. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_avro_value_protocol.py +0 -0
  64. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cancellation_context.py +0 -0
  65. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cancellation_remaining_time.py +0 -0
  66. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cancellation_scope_admission.py +0 -0
  67. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cancellation_scope_authoring.py +0 -0
  68. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cancellation_scope_boundary.py +0 -0
  69. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cancellation_scope_opening.py +0 -0
  70. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_child_workflow_policies.py +0 -0
  71. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_ci_checkout.py +0 -0
  72. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_ci_integration_endpoint.py +0 -0
  73. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_client.py +0 -0
  74. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_client_telemetry.py +0 -0
  75. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_client_timeouts.py +0 -0
  76. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_codec_regression_corpus.py +0 -0
  77. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  78. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_committed_cancellation_scope_groups.py +0 -0
  79. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_committed_cancellation_scope_history.py +0 -0
  80. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_committed_cancellation_scope_replay.py +0 -0
  81. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_condition_replay_drift.py +0 -0
  82. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_control_plane_parity_fixtures.py +0 -0
  83. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cooperative_cancellation.py +0 -0
  84. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cooperative_cancellation_client.py +0 -0
  85. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cooperative_cancellation_worker.py +0 -0
  86. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_cooperative_remote_worker.py +0 -0
  87. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_docstring_cross_references.py +0 -0
  88. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_durable_selection.py +0 -0
  89. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_errors.py +0 -0
  90. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_external_storage.py +0 -0
  91. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_external_task_input.py +0 -0
  92. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_external_task_result.py +0 -0
  93. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_failure_reporting.py +0 -0
  94. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_golden_history_replay.py +0 -0
  95. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_history_bundle_schema2.py +0 -0
  96. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_history_bundle_verify.py +0 -0
  97. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_history_event_contract.py +0 -0
  98. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_invocable.py +0 -0
  99. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_local_failure_reporting.py +0 -0
  100. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_message_streams.py +0 -0
  101. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_metric_routes.py +0 -0
  102. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_metrics.py +0 -0
  103. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_nexus_durable_wait.py +0 -0
  104. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_nexus_namespace.py +0 -0
  105. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_nexus_wire.py +0 -0
  106. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_order_processing_example.py +0 -0
  107. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_parallel_completion_order_replay.py +0 -0
  108. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_parallel_saga_authoring.py +0 -0
  109. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_patch_history.py +0 -0
  110. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_pending_operation_replay.py +0 -0
  111. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_poll_failure_backoff.py +0 -0
  112. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_prepared_local_activity_client.py +0 -0
  113. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_prepared_local_activity_groups.py +0 -0
  114. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_prepared_local_activity_policies.py +0 -0
  115. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_prepared_local_activity_worker.py +0 -0
  116. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_public_boundary_scanner.py +0 -0
  117. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_pypi_project_surface.py +0 -0
  118. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_python_conformance.py +0 -0
  119. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_queries.py +0 -0
  120. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_query_task_authority.py +0 -0
  121. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_query_timeout_retry.py +0 -0
  122. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_readme_quickstart.py +0 -0
  123. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_redrive_replay.py +0 -0
  124. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_registration_ambiguity.py +0 -0
  125. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_release_metadata.py +0 -0
  126. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_replay.py +0 -0
  127. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_replay_clock.py +0 -0
  128. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_replay_conformance.py +0 -0
  129. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_replay_regression_corpus.py +0 -0
  130. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_replay_verify.py +0 -0
  131. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_retry_policy.py +0 -0
  132. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_run_cancellation_scope_replay.py +0 -0
  133. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_runtime_external_payload_transport.py +0 -0
  134. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_schedules.py +0 -0
  135. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_scoped_run_cancellation_context.py +0 -0
  136. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_sdk_metrics_contract.py +0 -0
  137. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_serializer.py +0 -0
  138. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_signals.py +0 -0
  139. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_sleep.py +0 -0
  140. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_standalone_activity_client.py +0 -0
  141. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_sticky_execution.py +0 -0
  142. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_storage_admission.py +0 -0
  143. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_sync.py +0 -0
  144. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_sync_activities.py +0 -0
  145. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_telemetry.py +0 -0
  146. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_telemetry_carrier.py +0 -0
  147. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_testing_failures.py +0 -0
  148. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_testing_harness.py +0 -0
  149. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_testing_nexus.py +0 -0
  150. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_threaded_heartbeat_example.py +0 -0
  151. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_tls_trust.py +0 -0
  152. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_transport_diagnostics.py +0 -0
  153. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_update_signal_condition_replay.py +0 -0
  154. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_updates.py +0 -0
  155. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_wait_condition.py +0 -0
  156. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_worker_deregistration.py +0 -0
  157. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_worker_observation.py +0 -0
  158. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_worker_session.py +0 -0
  159. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_worker_telemetry_wire.py +0 -0
  160. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_workflow_entry_arguments.py +0 -0
  161. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_workflow_entrypoint_validation.py +0 -0
  162. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_workflow_handler_validation.py +0 -0
  163. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_workflow_identity.py +0 -0
  164. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_workflow_result_timeout.py +0 -0
  165. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/tests/test_workflow_streams.py +0 -0
  166. {durable_workflow-2.11.0 → durable_workflow-2.11.2}/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.11.0
3
+ Version: 2.11.2
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.11.0"
7
+ version = "2.11.2"
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"
@@ -76,8 +76,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
76
76
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
77
77
 
78
78
  [tool.durable-workflow]
79
- product-train = "2.11.0"
80
- registry-version = "2.11.0"
79
+ product-train = "2.11.2"
80
+ registry-version = "2.11.2"
81
81
  supported-server-versions = "2.8.0"
82
82
  worker-protocol-version = "1.20"
83
83
  control-plane-version = "2"
@@ -0,0 +1,55 @@
1
+ """Negotiated long-poll responses and early transport failure diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ MEDIA_TYPE = "application/vnd.durable-workflow.poll.v1+json"
13
+ ACCEPT = f"{MEDIA_TYPE}, application/json;q=0.9"
14
+ _SCHEMA = "durable-workflow.poll-transport.v1"
15
+ POLL_PATHS = frozenset(
16
+ f"/worker/{kind}-tasks/poll" for kind in ("workflow", "activity", "query", "update-validation")
17
+ )
18
+ _HEADERS = frozenset(("retry-after", "x-durable-workflow-protocol-version"))
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ def unwrap_response(response: httpx.Response) -> httpx.Response:
23
+ if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != MEDIA_TYPE:
24
+ return response
25
+
26
+ try:
27
+ envelope = response.json()
28
+ if not isinstance(envelope, dict) or envelope.get("schema") != _SCHEMA:
29
+ raise ValueError("unrecognized poll transport")
30
+ status, headers, body = envelope.get("status"), envelope.get("headers"), envelope.get("body")
31
+ if (type(status) is not int or not 200 <= status < 600
32
+ or response.status_code not in (200, status)
33
+ or not isinstance(body, dict) or not isinstance(headers, dict)
34
+ or any(key not in _HEADERS or not isinstance(value, str)
35
+ or "\r" in value or "\n" in value for key, value in headers.items())):
36
+ raise ValueError("invalid poll transport result")
37
+ # Apply the original status and retry headers before the normal client
38
+ # error handling. Outer HTTP 200 may have been committed by progress.
39
+ return httpx.Response(status, headers=headers, json=body, request=response.request)
40
+ except (ValueError, TypeError) as error:
41
+ raise httpx.RemoteProtocolError("Invalid worker poll transport response.", request=response.request) from error
42
+
43
+
44
+ def warn_early_cutoff(path: str, body: Any, started: float, error: Exception) -> None:
45
+ wait = body.get("timeout_seconds") if isinstance(body, Mapping) else None
46
+ observed = time.monotonic() - started
47
+ if isinstance(wait, bool) or not isinstance(wait, int | float) or wait <= 0 or observed >= wait:
48
+ return
49
+ if isinstance(error, httpx.HTTPStatusError) and error.response.status_code not in (502, 504):
50
+ return
51
+ log.warning(
52
+ "Worker long poll %s ended after %.3fs before its configured wait of %.3fs (%s). "
53
+ "Check proxy and HTTP timeout budgets.",
54
+ path, observed, wait, type(error).__name__,
55
+ )
@@ -57,6 +57,10 @@ from ._failure_reporting import (
57
57
  prepare_failure_request,
58
58
  )
59
59
  from ._metric_routes import route_for_metrics as _route_for_metrics
60
+ from ._poll_transport import ACCEPT as _POLL_ACCEPT
61
+ from ._poll_transport import POLL_PATHS as _POLL_PATHS
62
+ from ._poll_transport import unwrap_response as _unwrap_poll_response
63
+ from ._poll_transport import warn_early_cutoff as _warn_poll_cutoff
60
64
  from ._query_task_authority import QueryTaskAuthority, current_query_authority
61
65
  from .errors import (
62
66
  REQUEST_ID_CONFLICT,
@@ -1882,6 +1886,7 @@ class Client:
1882
1886
 
1883
1887
  query_poll = worker and path == "/worker/query-tasks/poll"
1884
1888
  activity_poll = worker and path == "/worker/activity-tasks/poll"
1889
+ progress_poll = worker and method.upper() == "POST" and path in _POLL_PATHS
1885
1890
  if query_poll:
1886
1891
  current_query_authority.set(None)
1887
1892
  if activity_poll:
@@ -1931,15 +1936,34 @@ class Client:
1931
1936
  request_timeout = self.timeout if timeout is None else timeout
1932
1937
  if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
1933
1938
  request_timeout = min(request_timeout, activity_authority.remaining())
1934
- resp = await self._http.request(
1939
+ headers = self._discovery_headers() if discovery else self._headers(worker=worker)
1940
+ if progress_poll:
1941
+ headers["Accept"] = _POLL_ACCEPT
1942
+ pending = self._http.request(
1935
1943
  method,
1936
1944
  f"/api{path}",
1937
- headers=self._discovery_headers() if discovery else self._headers(worker=worker),
1945
+ headers=headers,
1938
1946
  json=request_json,
1939
1947
  timeout=request_timeout,
1940
1948
  )
1941
- # Raise HTTPStatusError for 4xx/5xx so retry policy can catch it
1942
- resp.raise_for_status()
1949
+ try:
1950
+ if progress_poll:
1951
+ try:
1952
+ resp = await asyncio.wait_for(pending, timeout=request_timeout)
1953
+ except asyncio.TimeoutError as error:
1954
+ raise httpx.ReadTimeout(
1955
+ "Worker poll exceeded its absolute HTTP attempt deadline.",
1956
+ request=self._http.build_request(method, f"/api{path}"),
1957
+ ) from error
1958
+ resp = _unwrap_poll_response(resp)
1959
+ else:
1960
+ resp = await pending
1961
+ # Interpret negotiated late errors before the usual retry policy.
1962
+ resp.raise_for_status()
1963
+ except (httpx.TransportError, httpx.HTTPStatusError) as error:
1964
+ if progress_poll:
1965
+ _warn_poll_cutoff(path, request_json, request_started, error)
1966
+ raise
1943
1967
  return resp
1944
1968
 
1945
1969
  try:
@@ -259,8 +259,8 @@ class _PollFailureBackoff:
259
259
  self._last_summary: str | None = None
260
260
  self._last_warning_at = 0.0
261
261
 
262
- def retry_delay(self, error: Exception, task_kind: str) -> float:
263
- delay = random.uniform(self._ceiling / 2, self._ceiling)
262
+ def retry_delay(self, error: Exception, task_kind: str, *, minimum_delay: float = 0.0) -> float:
263
+ delay = max(minimum_delay, random.uniform(self._ceiling / 2, self._ceiling))
264
264
  self._ceiling = min(30.0, self._ceiling * 2)
265
265
  summary = _poll_error_summary(error)
266
266
  now = time.monotonic()
@@ -280,6 +280,28 @@ def _poll_capacity_delay(error: BaseException, task_kind: str, task_queue: str)
280
280
  return error.poll_capacity_backpressure_delay(task_kind, task_queue)
281
281
 
282
282
 
283
+ def _backend_lock_poll_delay(
284
+ error: Exception, task_kind: str, task_queue: str, namespace: object,
285
+ ) -> int | None:
286
+ if not isinstance(error, ServerError) or error.status != 503:
287
+ return None
288
+ body = error.body
289
+ if (
290
+ not isinstance(body, dict) or not isinstance(namespace, str) or not namespace
291
+ or body.get("reason") != "backend_lock_pressure"
292
+ or body.get("poll_status") != "backend_lock_pressure"
293
+ or "task" not in body or body["task"] is not None
294
+ or body.get("task_kind") != task_kind + "_task"
295
+ or body.get("task_queue") != task_queue or body.get("namespace") != namespace
296
+ or ("retryable" in body and body["retryable"] is not True)
297
+ or ("request_admitted" in body and body["request_admitted"] is not False)
298
+ or type(body.get("retry_after_seconds")) is not int
299
+ or not 0 < body["retry_after_seconds"] <= 60
300
+ ):
301
+ return None
302
+ return int(body["retry_after_seconds"])
303
+
304
+
283
305
  def _command_payload_codec(codec: object) -> str:
284
306
  if codec is None:
285
307
  return serializer.AVRO_CODEC
@@ -4468,6 +4490,7 @@ class Worker:
4468
4490
  ) -> WorkflowExecution:
4469
4491
  deadline = time.monotonic() + timeout
4470
4492
  next_task_kind = "workflow"
4493
+ failures = {"workflow": _PollFailureBackoff(), "activity": _PollFailureBackoff()}
4471
4494
 
4472
4495
  while not self._stop.is_set():
4473
4496
  desc = await self.client.describe_workflow(workflow_id)
@@ -4494,9 +4517,14 @@ class Worker:
4494
4517
  task_kinds=self._workflow_poll_task_kinds(),
4495
4518
  history_page_size=WORKFLOW_HISTORY_PAGE_SIZE,
4496
4519
  )
4497
- except BaseException:
4520
+ except BaseException as error:
4498
4521
  self._release_workflow_capacity()
4522
+ if isinstance(error, Exception) and await self._retry_run_until_poll_refusal(
4523
+ error, "workflow", poll_start, deadline, failures["workflow"],
4524
+ ):
4525
+ continue
4499
4526
  raise
4527
+ failures["workflow"].reset()
4500
4528
  if self._stop.is_set():
4501
4529
  self._release_workflow_capacity()
4502
4530
  raise asyncio.CancelledError
@@ -4543,12 +4571,20 @@ class Worker:
4543
4571
  await asyncio.sleep(poll_interval)
4544
4572
  continue
4545
4573
  poll_start = time.perf_counter()
4546
- task = await self.client.poll_activity_task(
4547
- worker_id=self.worker_id,
4548
- task_queue=self.task_queue,
4549
- timeout=self._poll_timeout,
4550
- build_id=self.build_id,
4551
- )
4574
+ try:
4575
+ task = await self.client.poll_activity_task(
4576
+ worker_id=self.worker_id,
4577
+ task_queue=self.task_queue,
4578
+ timeout=self._poll_timeout,
4579
+ build_id=self.build_id,
4580
+ )
4581
+ except Exception as error:
4582
+ if await self._retry_run_until_poll_refusal(
4583
+ error, "activity", poll_start, deadline, failures["activity"],
4584
+ ):
4585
+ continue
4586
+ raise
4587
+ failures["activity"].reset()
4552
4588
  if self._stop.is_set():
4553
4589
  raise asyncio.CancelledError
4554
4590
  self._record_poll_metrics(
@@ -4566,6 +4602,28 @@ class Worker:
4566
4602
 
4567
4603
  raise asyncio.CancelledError
4568
4604
 
4605
+ async def _retry_run_until_poll_refusal(
4606
+ self, error: Exception, kind: str, poll_start: float, deadline: float,
4607
+ failures: _PollFailureBackoff,
4608
+ ) -> bool:
4609
+ delay = _backend_lock_poll_delay(
4610
+ error, kind, self.task_queue, getattr(self.client, "namespace", None),
4611
+ )
4612
+ if delay is None:
4613
+ return False
4614
+ self._record_poll_metrics(kind, "error", time.perf_counter() - poll_start)
4615
+ backoff = failures.retry_delay(error, kind, minimum_delay=delay)
4616
+ remaining = deadline - time.monotonic()
4617
+ if remaining <= 0:
4618
+ raise asyncio.TimeoutError
4619
+ # Both the public caller timer and this local wait retain their original
4620
+ # deadline. Stop interrupts the backoff without another poll or dispatch.
4621
+ with contextlib.suppress(asyncio.TimeoutError):
4622
+ await asyncio.wait_for(self._stop.wait(), timeout=min(backoff, remaining))
4623
+ if time.monotonic() >= deadline:
4624
+ raise asyncio.TimeoutError
4625
+ return True
4626
+
4569
4627
  async def _run_until_activity_task(self, task: dict[str, Any]) -> None:
4570
4628
  task_start = time.perf_counter()
4571
4629
  outcome = "error"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.11.0
3
+ Version: 2.11.2
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
@@ -10,6 +10,7 @@ src/durable_workflow/_cancellation_scope_history.py
10
10
  src/durable_workflow/_cooperative_cancellation.py
11
11
  src/durable_workflow/_failure_reporting.py
12
12
  src/durable_workflow/_metric_routes.py
13
+ src/durable_workflow/_poll_transport.py
13
14
  src/durable_workflow/_prepared_local_activity.py
14
15
  src/durable_workflow/_query_task_authority.py
15
16
  src/durable_workflow/_sticky_workflow_cache.py
@@ -104,6 +105,7 @@ tests/test_parallel_saga_authoring.py
104
105
  tests/test_patch_history.py
105
106
  tests/test_pending_operation_replay.py
106
107
  tests/test_poll_failure_backoff.py
108
+ tests/test_poll_transport.py
107
109
  tests/test_prepared_local_activity_client.py
108
110
  tests/test_prepared_local_activity_groups.py
109
111
  tests/test_prepared_local_activity_policies.py
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager, suppress
9
+ from typing import Any
10
+
11
+ import httpx
12
+ import pytest
13
+
14
+ from durable_workflow._poll_transport import MEDIA_TYPE, unwrap_response
15
+ from durable_workflow.client import Client
16
+ from durable_workflow.errors import InvalidArgument, ServerError
17
+ from durable_workflow.retry_policy import TransportRetryPolicy
18
+
19
+
20
+ def envelope(status: int, body: Any, **changes: Any) -> dict[str, Any]:
21
+ return {"schema": "durable-workflow.poll-transport.v1", "status": status, "headers": {},
22
+ "body": body, **changes}
23
+
24
+
25
+ def wire_response(body: Any, *, status: int = 200) -> httpx.Response:
26
+ return httpx.Response(status, headers={"content-type": MEDIA_TYPE.upper() + "; charset=utf-8"},
27
+ json=body, request=httpx.Request("POST", "http://fixture/api/worker/workflow-tasks/poll"))
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ @pytest.mark.parametrize("kind", ["workflow", "activity", "query", "update-validation"])
32
+ @pytest.mark.parametrize("negotiated", [False, True])
33
+ async def test_poll_negotiation_accepts_legacy_and_progress(kind: str, negotiated: bool) -> None:
34
+ async def serve(request: httpx.Request) -> httpx.Response:
35
+ assert request.headers["accept"] == MEDIA_TYPE + ", application/json;q=0.9"
36
+ data = {"task": None, "poll_status": "empty"}
37
+ if negotiated:
38
+ return httpx.Response(200, headers={"content-type": MEDIA_TYPE},
39
+ content=b"\n\n" + json.dumps(envelope(200, data)).encode())
40
+ return httpx.Response(200, json=data)
41
+
42
+ async with Client("http://fixture") as client:
43
+ await client._http.aclose()
44
+ client._http = httpx.AsyncClient(base_url="http://fixture", transport=httpx.MockTransport(serve))
45
+ poll = getattr(client, f"poll_{kind.replace('-', '_')}_task")
46
+ assert await poll(worker_id="fixture-worker", task_queue="fixture-queue", timeout=0) is None
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_non_poll_request_keeps_ordinary_response() -> None:
51
+ async def serve(request: httpx.Request) -> httpx.Response:
52
+ assert MEDIA_TYPE not in request.headers.get("accept", "")
53
+ return httpx.Response(200, json={"healthy": True})
54
+
55
+ async with Client("http://fixture") as client:
56
+ await client._http.aclose()
57
+ client._http = httpx.AsyncClient(base_url="http://fixture", transport=httpx.MockTransport(serve))
58
+ assert await client.health() == {"healthy": True}
59
+
60
+
61
+ def test_unwrap_preserves_task_values_and_original_headers() -> None:
62
+ body = {"task": {"counter": 9007199254740993, "value": 1.0, "payload": {"codec": "avro", "data": "AA=="}}}
63
+ response = unwrap_response(wire_response(envelope(200, body, headers={
64
+ "x-durable-workflow-protocol-version": "1.20", "retry-after": "2",
65
+ })))
66
+ assert response.json() == body
67
+ assert response.headers["x-durable-workflow-protocol-version"] == "1.20"
68
+ assert response.headers["retry-after"] == "2"
69
+ assert len(response.content) == int(response.headers["content-length"])
70
+
71
+
72
+ @pytest.mark.parametrize("change", [
73
+ {"schema": "unknown"}, {"status": True}, {"status": 199}, {"status": 600},
74
+ {"body": []}, {"headers": []}, {"headers": {"retry-after": 2}},
75
+ {"headers": {"authorization": "not-allowed"}}, {"headers": {"retry-after": "2\r\nX-Test: bad"}},
76
+ ])
77
+ def test_invalid_transport_is_not_silently_treated_as_an_empty_poll(change: dict[str, Any]) -> None:
78
+ response = wire_response(envelope(200, {"task": None}) | change)
79
+ with pytest.raises(httpx.RemoteProtocolError, match="Invalid worker poll transport"):
80
+ unwrap_response(response)
81
+
82
+
83
+ def test_outer_http_error_cannot_be_rewritten_as_success() -> None:
84
+ response = wire_response(envelope(200, {"task": None}), status=503)
85
+ with pytest.raises(httpx.RemoteProtocolError):
86
+ unwrap_response(response)
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ @pytest.mark.parametrize("status", [409, 422, 429, 503])
91
+ async def test_late_errors_retain_typed_error_and_retry_semantics(status: int) -> None:
92
+ attempts = []
93
+ retry_policy = TransportRetryPolicy(max_attempts=2, initial_backoff_seconds=0, jitter=False)
94
+
95
+ async def serve(request: httpx.Request) -> httpx.Response:
96
+ attempts.append(json.loads(request.content)["poll_request_id"])
97
+ reason = "long_poll_capacity_exhausted" if status == 429 else "fixture_late_failure"
98
+ body = {"error": "fixture failure", "reason": reason, "retryable": True}
99
+ if status == 429:
100
+ body.update({"task": None, "poll_status": reason, "task_kind": "workflow_task",
101
+ "task_queue": "fixture-queue", "retry_after_seconds": 2})
102
+ return httpx.Response(200, headers={"content-type": MEDIA_TYPE}, json=envelope(
103
+ status, body,
104
+ headers={"retry-after": "2"},
105
+ ))
106
+
107
+ async with Client("http://fixture", retry_policy=retry_policy) as client:
108
+ await client._http.aclose()
109
+ client._http = httpx.AsyncClient(base_url="http://fixture", transport=httpx.MockTransport(serve))
110
+ with pytest.raises(InvalidArgument if status == 422 else ServerError) as raised:
111
+ await client.poll_workflow_task(worker_id="fixture-worker", task_queue="fixture-queue", timeout=0)
112
+ if isinstance(raised.value, ServerError):
113
+ assert raised.value.status == status
114
+ assert raised.value.reason() == ("long_poll_capacity_exhausted" if status == 429 else "fixture_late_failure")
115
+ assert len(attempts) == (2 if status == 503 else 1)
116
+ assert len(set(attempts)) == 1
117
+
118
+
119
+ @asynccontextmanager
120
+ async def progressing_peer(
121
+ *, complete_after: float, requests: list[dict[str, Any]] | None = None,
122
+ ) -> AsyncIterator[tuple[str, list[asyncio.Event]]]:
123
+ closed = asyncio.Event()
124
+ started = asyncio.Event()
125
+ handlers: set[asyncio.Task[None]] = set()
126
+
127
+ async def serve(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
128
+ task = asyncio.current_task()
129
+ assert task is not None
130
+ handlers.add(task)
131
+ try:
132
+ headers = await reader.readuntil(b"\r\n\r\n")
133
+ for header in headers.split(b"\r\n"):
134
+ if header.lower().startswith(b"content-length:"):
135
+ body = await reader.readexactly(int(header.split(b":", 1)[1]))
136
+ if requests is not None:
137
+ requests.append(json.loads(body))
138
+ writer.write(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n"
139
+ + f"Content-Type: {MEDIA_TYPE}\r\n\r\n".encode())
140
+ started.set()
141
+ finish = time.monotonic() + complete_after
142
+ while time.monotonic() < finish:
143
+ writer.write(b"1\r\n\n\r\n")
144
+ await writer.drain()
145
+ if reader.at_eof():
146
+ return
147
+ await asyncio.sleep(0.01)
148
+ if not reader.at_eof():
149
+ data = json.dumps(envelope(200, {"task": None, "poll_status": "empty"})).encode()
150
+ writer.write(f"{len(data):x}\r\n".encode() + data + b"\r\n0\r\n\r\n")
151
+ await writer.drain()
152
+ except (ConnectionError, asyncio.IncompleteReadError):
153
+ pass
154
+ finally:
155
+ writer.close()
156
+ with suppress(ConnectionError):
157
+ await writer.wait_closed()
158
+ handlers.discard(task)
159
+ closed.set()
160
+
161
+ server = await asyncio.start_server(serve, "127.0.0.1", 0)
162
+ try:
163
+ assert server.sockets
164
+ yield f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}", [started, closed]
165
+ finally:
166
+ server.close()
167
+ await server.wait_closed()
168
+ for task in list(handlers):
169
+ task.cancel()
170
+ if handlers:
171
+ await asyncio.gather(*handlers, return_exceptions=True)
172
+
173
+
174
+ @pytest.mark.asyncio
175
+ @pytest.mark.parametrize("kind", ["workflow", "activity", "query", "update-validation"])
176
+ async def test_progress_cannot_renew_the_absolute_attempt_deadline(
177
+ kind: str, caplog: pytest.LogCaptureFixture,
178
+ ) -> None:
179
+ async with (
180
+ progressing_peer(complete_after=0.6) as (endpoint, events),
181
+ Client(endpoint, retry_policy=TransportRetryPolicy(max_attempts=1)) as client,
182
+ ):
183
+ started = time.monotonic()
184
+ request = client._request("POST", f"/worker/{kind}-tasks/poll", worker=True,
185
+ json={"timeout_seconds": 30}, timeout=0.12)
186
+ with caplog.at_level(logging.WARNING), pytest.raises(httpx.ReadTimeout, match="absolute HTTP attempt"):
187
+ await asyncio.wait_for(request, timeout=2)
188
+ assert time.monotonic() - started < 0.5
189
+ await asyncio.wait_for(events[1].wait(), timeout=1)
190
+ assert "configured wait of 30.000s" in caplog.text
191
+ assert "ended after" in caplog.text
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_public_poll_keeps_its_retry_budget_and_logical_identity() -> None:
196
+ requests: list[dict[str, Any]] = []
197
+ async with (
198
+ progressing_peer(complete_after=1.2, requests=requests) as (endpoint, _events),
199
+ Client(endpoint, retry_policy=TransportRetryPolicy(max_attempts=1)) as client,
200
+ ):
201
+ started = time.monotonic()
202
+ result = await client.poll_workflow_task_response(
203
+ worker_id="fixture-worker", task_queue="fixture-queue", timeout=0,
204
+ )
205
+ assert result == {"task": None, "poll_status": "timeout"}
206
+ assert 1.8 < time.monotonic() - started < 3
207
+ assert len(requests) == 2
208
+ assert len({request["poll_request_id"] for request in requests}) == 1
209
+
210
+
211
+ @pytest.mark.asyncio
212
+ @pytest.mark.parametrize("status", [502, 504])
213
+ async def test_gateway_cutoff_warns_with_observed_and_configured_wait(
214
+ status: int, caplog: pytest.LogCaptureFixture,
215
+ ) -> None:
216
+ async def serve(_request: httpx.Request) -> httpx.Response:
217
+ return httpx.Response(status, text="fixture gateway error")
218
+
219
+ async with Client("http://fixture", retry_policy=TransportRetryPolicy(max_attempts=1)) as client:
220
+ await client._http.aclose()
221
+ client._http = httpx.AsyncClient(base_url="http://fixture", transport=httpx.MockTransport(serve))
222
+ with caplog.at_level(logging.WARNING), pytest.raises(ServerError) as raised:
223
+ await client.poll_workflow_task(worker_id="fixture-worker", task_queue="fixture-queue", timeout=30)
224
+ assert raised.value.status == status
225
+ assert "configured wait of 30.000s" in caplog.text
226
+ assert "ended after" in caplog.text
227
+
228
+
229
+ @pytest.mark.asyncio
230
+ async def test_progress_completes_inside_the_attempt_deadline() -> None:
231
+ async with (
232
+ progressing_peer(complete_after=0.12) as (endpoint, _events),
233
+ Client(endpoint, retry_policy=TransportRetryPolicy(max_attempts=1)) as client,
234
+ ):
235
+ result = await client._request("POST", "/worker/workflow-tasks/poll", worker=True,
236
+ json={"timeout_seconds": 1}, timeout=0.5)
237
+ assert result == {"task": None, "poll_status": "empty"}
238
+
239
+
240
+ @pytest.mark.asyncio
241
+ async def test_cancellation_closes_a_progressing_request_without_a_cutoff_warning(
242
+ caplog: pytest.LogCaptureFixture,
243
+ ) -> None:
244
+ async with (
245
+ progressing_peer(complete_after=1) as (endpoint, events),
246
+ Client(endpoint, retry_policy=TransportRetryPolicy(max_attempts=1)) as client,
247
+ ):
248
+ request = asyncio.create_task(client._request("POST", "/worker/workflow-tasks/poll", worker=True,
249
+ json={"timeout_seconds": 30}, timeout=5))
250
+ await asyncio.wait_for(events[0].wait(), timeout=1)
251
+ request.cancel()
252
+ with pytest.raises(asyncio.CancelledError):
253
+ await request
254
+ await asyncio.wait_for(events[1].wait(), timeout=1)
255
+ assert "ended after" not in caplog.text