durable-workflow 2.6.2__tar.gz → 2.7.0__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 (144) hide show
  1. {durable_workflow-2.6.2/src/durable_workflow.egg-info → durable_workflow-2.7.0}/PKG-INFO +4 -1
  2. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/pyproject.toml +7 -3
  3. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_activity_process.py +6 -0
  4. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/client.py +154 -16
  5. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/errors.py +76 -3
  6. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/interceptors.py +9 -0
  7. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/retry_policy.py +18 -6
  8. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/sync.py +23 -4
  9. durable_workflow-2.7.0/src/durable_workflow/telemetry.py +341 -0
  10. durable_workflow-2.7.0/src/durable_workflow/telemetry_carrier.py +223 -0
  11. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/worker.py +80 -4
  12. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/workflow_updates_conformance.py +66 -8
  13. {durable_workflow-2.6.2 → durable_workflow-2.7.0/src/durable_workflow.egg-info}/PKG-INFO +4 -1
  14. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/SOURCES.txt +6 -0
  15. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/requires.txt +4 -0
  16. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_client.py +246 -3
  17. durable_workflow-2.7.0/tests/test_client_telemetry.py +93 -0
  18. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_errors.py +73 -0
  19. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_regression_corpus.py +62 -1
  20. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_retry_policy.py +12 -0
  21. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sync.py +25 -3
  22. durable_workflow-2.7.0/tests/test_telemetry.py +293 -0
  23. durable_workflow-2.7.0/tests/test_telemetry_carrier.py +158 -0
  24. durable_workflow-2.7.0/tests/test_worker_telemetry_wire.py +205 -0
  25. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_updates_conformance.py +10 -1
  26. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/LICENSE +0 -0
  27. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/README.md +0 -0
  28. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/setup.cfg +0 -0
  29. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/__init__.py +0 -0
  30. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_avro.py +0 -0
  31. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  32. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  33. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  34. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  35. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_query_task_authority.py +0 -0
  36. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  37. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/activity.py +0 -0
  38. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/auth_composition.py +0 -0
  39. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/cancellation.py +0 -0
  40. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/external_storage.py +0 -0
  41. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/external_task_input.py +0 -0
  42. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/external_task_result.py +0 -0
  43. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  44. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/invocable.py +0 -0
  45. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/metrics.py +0 -0
  46. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/nexus.py +0 -0
  47. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/py.typed +0 -0
  48. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/python_conformance.py +0 -0
  49. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/replay_conformance.py +0 -0
  50. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/replay_verify.py +0 -0
  51. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/serializer.py +0 -0
  52. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/testing.py +0 -0
  53. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/worker_session.py +0 -0
  54. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/workflow.py +0 -0
  55. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  56. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  57. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  58. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_activity_cancellation_policies.py +0 -0
  59. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_activity_context.py +0 -0
  60. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_activity_process.py +0 -0
  61. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_auth_composition.py +0 -0
  62. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_avro_value_benchmark.py +0 -0
  63. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_avro_value_protocol.py +0 -0
  64. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_context.py +0 -0
  65. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_remaining_time.py +0 -0
  66. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_admission.py +0 -0
  67. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_authoring.py +0 -0
  68. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_boundary.py +0 -0
  69. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_opening.py +0 -0
  70. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_child_workflow_policies.py +0 -0
  71. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_ci_checkout.py +0 -0
  72. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_ci_integration_endpoint.py +0 -0
  73. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_client_timeouts.py +0 -0
  74. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_codec_regression_corpus.py +0 -0
  75. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  76. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  77. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  78. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  79. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_condition_replay_drift.py +0 -0
  80. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  81. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation.py +0 -0
  82. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation_client.py +0 -0
  83. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation_worker.py +0 -0
  84. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_remote_worker.py +0 -0
  85. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_docstring_cross_references.py +0 -0
  86. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_durable_selection.py +0 -0
  87. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_external_storage.py +0 -0
  88. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_external_task_input.py +0 -0
  89. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_external_task_result.py +0 -0
  90. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_golden_history_replay.py +0 -0
  91. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_history_bundle_verify.py +0 -0
  92. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_history_event_contract.py +0 -0
  93. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_invocable.py +0 -0
  94. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_message_streams.py +0 -0
  95. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_metrics.py +0 -0
  96. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_nexus_durable_wait.py +0 -0
  97. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_nexus_namespace.py +0 -0
  98. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_nexus_wire.py +0 -0
  99. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_order_processing_example.py +0 -0
  100. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_parallel_completion_order_replay.py +0 -0
  101. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_parallel_saga_authoring.py +0 -0
  102. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_patch_history.py +0 -0
  103. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_pending_operation_replay.py +0 -0
  104. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_client.py +0 -0
  105. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_groups.py +0 -0
  106. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_policies.py +0 -0
  107. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_worker.py +0 -0
  108. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_public_boundary_scanner.py +0 -0
  109. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_pypi_project_surface.py +0 -0
  110. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_python_conformance.py +0 -0
  111. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_queries.py +0 -0
  112. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_query_task_authority.py +0 -0
  113. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_query_timeout_retry.py +0 -0
  114. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_readme_quickstart.py +0 -0
  115. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_redrive_replay.py +0 -0
  116. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_registration_ambiguity.py +0 -0
  117. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_release_metadata.py +0 -0
  118. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay.py +0 -0
  119. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_clock.py +0 -0
  120. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_conformance.py +0 -0
  121. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_verify.py +0 -0
  122. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  123. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_runtime_external_payload_transport.py +0 -0
  124. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_schedules.py +0 -0
  125. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  126. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_serializer.py +0 -0
  127. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_signals.py +0 -0
  128. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sleep.py +0 -0
  129. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_standalone_activity_client.py +0 -0
  130. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sticky_execution.py +0 -0
  131. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_storage_admission.py +0 -0
  132. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sync_activities.py +0 -0
  133. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_testing_harness.py +0 -0
  134. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_update_signal_condition_replay.py +0 -0
  135. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_updates.py +0 -0
  136. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_wait_condition.py +0 -0
  137. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_worker.py +0 -0
  138. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_worker_observation.py +0 -0
  139. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_worker_session.py +0 -0
  140. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_entry_arguments.py +0 -0
  141. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_entrypoint_validation.py +0 -0
  142. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_handler_validation.py +0 -0
  143. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_result_timeout.py +0 -0
  144. {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_streams.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.6.2
3
+ Version: 2.7.0
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
@@ -21,6 +21,8 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: httpx>=0.27
23
23
  Requires-Dist: fastavro<2,>=1.12.2
24
+ Provides-Extra: telemetry
25
+ Requires-Dist: opentelemetry-api<2,>=1.45.1; extra == "telemetry"
24
26
  Provides-Extra: dev
25
27
  Requires-Dist: pytest>=8.0; extra == "dev"
26
28
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -29,6 +31,7 @@ Requires-Dist: playwright<2,>=1.48; extra == "dev"
29
31
  Requires-Dist: PyYAML>=6.0; extra == "dev"
30
32
  Requires-Dist: ruff>=0.4; extra == "dev"
31
33
  Requires-Dist: tomli>=2; python_version < "3.11" and extra == "dev"
34
+ Requires-Dist: opentelemetry-sdk==1.45.1; extra == "dev"
32
35
  Provides-Extra: prometheus
33
36
  Requires-Dist: prometheus-client>=0.20; extra == "prometheus"
34
37
  Provides-Extra: docs
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.6.2"
7
+ version = "2.7.0"
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"
@@ -38,6 +38,9 @@ dependencies = [
38
38
  ]
39
39
 
40
40
  [project.optional-dependencies]
41
+ telemetry = [
42
+ "opentelemetry-api>=1.45.1,<2",
43
+ ]
41
44
  dev = [
42
45
  "pytest>=8.0",
43
46
  "pytest-asyncio>=0.23",
@@ -46,6 +49,7 @@ dev = [
46
49
  "PyYAML>=6.0",
47
50
  "ruff>=0.4",
48
51
  "tomli>=2; python_version < '3.11'",
52
+ "opentelemetry-sdk==1.45.1",
49
53
  ]
50
54
  prometheus = [
51
55
  "prometheus-client>=0.20",
@@ -71,8 +75,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
75
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
76
 
73
77
  [tool.durable-workflow]
74
- product-train = "2.6.2"
75
- registry-version = "2.6.2"
78
+ product-train = "2.7.0"
79
+ registry-version = "2.7.0"
76
80
  supported-server-versions = "2.8.0"
77
81
  worker-protocol-version = "1.20"
78
82
  control-plane-version = "2"
@@ -40,6 +40,10 @@ class CallbackInvocation:
40
40
  info: ActivityInfo
41
41
  task: dict[str, Any]
42
42
  interceptors: tuple[WorkerInterceptor, ...] = ()
43
+ namespace: str | None = None
44
+ observability_supported: bool = False
45
+ remote: bool = False
46
+ execution_context: dict[str, Any] | None = None
43
47
 
44
48
  def serialize(self) -> bytes:
45
49
  try:
@@ -100,6 +104,8 @@ def _callback(directory: str, channel: socket.socket) -> None:
100
104
  context = ActivityInterceptorContext(
101
105
  worker_id=invocation.info.worker_id, task_queue=invocation.info.task_queue,
102
106
  task=invocation.task, activity_type=invocation.info.activity_type, args=invocation.args,
107
+ namespace=invocation.namespace, observability_supported=invocation.observability_supported,
108
+ remote=invocation.remote, execution_context=invocation.execution_context,
103
109
  )
104
110
 
105
111
  async def call(ctx: ActivityInterceptorContext) -> Any:
@@ -19,6 +19,7 @@ methods without repeating the id on every call.
19
19
  from __future__ import annotations
20
20
 
21
21
  import asyncio
22
+ import contextlib
22
23
  import copy
23
24
  import hashlib
24
25
  import json as json_module
@@ -47,6 +48,7 @@ from ._cancellation_scope_history import (
47
48
  from ._cooperative_cancellation import CancellationDelivery, CancellationRequest
48
49
  from ._query_task_authority import QueryTaskAuthority, current_query_authority
49
50
  from .errors import (
51
+ REQUEST_ID_CONFLICT,
50
52
  ExternalPayloadIntegrityMismatch,
51
53
  ExternalPayloadOversized,
52
54
  ExternalPayloadUnavailable,
@@ -55,6 +57,8 @@ from .errors import (
55
57
  RuntimeCapabilityUnsupported,
56
58
  RuntimeDiscoveryUnavailable,
57
59
  ServerError,
60
+ SignalFailed,
61
+ UpdateFailed,
58
62
  WorkflowCancelled,
59
63
  WorkflowFailed,
60
64
  WorkflowTerminated,
@@ -71,6 +75,8 @@ from .external_storage import (
71
75
  from .metrics import CLIENT_REQUEST_DURATION_SECONDS, CLIENT_REQUESTS, NOOP_METRICS, MetricsRecorder
72
76
  from .nexus import NexusOperationResult, nexus_request_payload
73
77
  from .retry_policy import TransportRetryPolicy
78
+ from .telemetry import ExecutionTelemetry, current_execution_telemetry
79
+ from .telemetry_carrier import supported as observability_supported
74
80
  from .worker_session import WorkerSessionOptions
75
81
 
76
82
  PROTOCOL_VERSION = "1.20"
@@ -239,6 +245,49 @@ def _normalize_base_url(base_url: str) -> str:
239
245
  return base_url.rstrip("/")
240
246
 
241
247
 
248
+ def _command_request_id(request_id: str | None, command: str) -> str:
249
+ """Return the caller command key for one logical signal or update call.
250
+
251
+ The key is the body field ``request_id`` (1-255 opaque, case-sensitive
252
+ bytes). A caller-supplied key is sent verbatim; otherwise one
253
+ ``sdk-python-<command>-<uuid4 hex>`` key is generated per logical call so
254
+ the transport retry policy resends the identical key.
255
+ """
256
+ return request_id or f"sdk-python-{command}-{uuid.uuid4().hex}"
257
+
258
+
259
+ def _attach_request_id(exc: BaseException, request_id: str) -> None:
260
+ """Record the sent command key on an exception as ``exc.request_id``.
261
+
262
+ Typed SDK failures declare the attribute; transport errors raised by
263
+ ``httpx`` receive it dynamically so a manual retry can reuse the key.
264
+ """
265
+ with contextlib.suppress(AttributeError, TypeError):
266
+ exc.request_id = request_id # type: ignore[attr-defined]
267
+
268
+
269
+ def _command_request_failure(exc: Exception, *, command: str, request_id: str) -> Exception:
270
+ """Return the exception a failed signal or update call should raise.
271
+
272
+ A ``request_id_conflict`` refusal that reached the caller as a generic
273
+ :class:`ServerError` becomes the command's typed failure. Every other
274
+ exception is returned unchanged, annotated with the key that was sent.
275
+ """
276
+ if isinstance(exc, ServerError) and exc.reason() == REQUEST_ID_CONFLICT:
277
+ message = exc.body.get("message") if isinstance(exc.body, dict) else None
278
+ if not isinstance(message, str) or not message:
279
+ message = f"{command} request id already retained with a different intent"
280
+ if command == "update" and isinstance(exc.body, dict):
281
+ return UpdateFailed(
282
+ message, status=exc.status, body=exc.body, reason=REQUEST_ID_CONFLICT, request_id=request_id,
283
+ )
284
+ return SignalFailed(
285
+ message, reason=REQUEST_ID_CONFLICT, status=exc.status, body=exc.body, request_id=request_id,
286
+ )
287
+ _attach_request_id(exc, request_id)
288
+ return exc
289
+
290
+
242
291
  def _route_for_metrics(path: str) -> str:
243
292
  clean_path = path.split("?", 1)[0]
244
293
  parts = [part for part in clean_path.strip("/").split("/") if part]
@@ -1346,9 +1395,17 @@ class WorkflowHandle:
1346
1395
  raise ValueError("run_id is required to describe a workflow run from a handle")
1347
1396
  return await self._client.describe_workflow_run(self.workflow_id, selected_run_id)
1348
1397
 
1349
- async def signal(self, signal_name: str, args: list[Any] | None = None) -> None:
1398
+ async def signal(
1399
+ self,
1400
+ signal_name: str,
1401
+ args: list[Any] | None = None,
1402
+ *,
1403
+ request_id: str | None = None,
1404
+ ) -> dict[str, Any]:
1350
1405
  """Deliver an external signal to this workflow. See :meth:`Client.signal_workflow`."""
1351
- await self._client.signal_workflow(self.workflow_id, signal_name, args=args)
1406
+ return await self._client.signal_workflow(
1407
+ self.workflow_id, signal_name, args=args, request_id=request_id,
1408
+ )
1352
1409
 
1353
1410
  async def append_message(
1354
1411
  self,
@@ -1592,12 +1649,15 @@ class Client:
1592
1649
  external_storage: ExternalStorageDriver | None = None,
1593
1650
  external_storage_threshold_bytes: int | None = None,
1594
1651
  external_storage_cache: ExternalPayloadCache | None = None,
1652
+ telemetry: ExecutionTelemetry | None = None,
1595
1653
  ) -> None:
1596
1654
  self.base_url = _normalize_base_url(base_url)
1597
1655
  self.token = token
1598
1656
  self.control_token = control_token
1599
1657
  self.worker_token = worker_token
1600
1658
  self.namespace = namespace
1659
+ self.telemetry = telemetry
1660
+ self._observability_capability = False
1601
1661
  self.timeout = timeout
1602
1662
  self.retry_policy = retry_policy or TransportRetryPolicy()
1603
1663
  self.metrics = metrics or NOOP_METRICS
@@ -2603,6 +2663,7 @@ class Client:
2603
2663
  credential. Other client methods continue to require the credential
2604
2664
  for their own protocol plane.
2605
2665
  """
2666
+ self._observability_capability = False
2606
2667
  result = await self._request("GET", "/cluster/info", discovery=True, context="get_cluster_info")
2607
2668
  if not isinstance(result, dict):
2608
2669
  raise ServerError(
@@ -2610,8 +2671,16 @@ class Client:
2610
2671
  {"reason": "invalid_cluster_info", "message": f"expected JSON object, got {type(result).__name__}"},
2611
2672
  )
2612
2673
  self._cluster_info = result
2674
+ self._observability_capability = observability_supported(result)
2613
2675
  return result
2614
2676
 
2677
+ def _with_observability_context(self, body: dict[str, Any]) -> dict[str, Any]:
2678
+ telemetry = self.telemetry or current_execution_telemetry()
2679
+ if telemetry is None or not self._observability_capability:
2680
+ return body
2681
+ carrier = telemetry.capture()
2682
+ return body if carrier is None else {**body, "observability_context": carrier}
2683
+
2615
2684
  async def _runtime_discovery(
2616
2685
  self,
2617
2686
  *,
@@ -2783,6 +2852,7 @@ class Client:
2783
2852
  # provider credentials. The worker keeps using the original client.
2784
2853
  scoped.worker_token = None
2785
2854
  scoped._cluster_info = None
2855
+ scoped._observability_capability = False
2786
2856
  scoped._cluster_info_lock = asyncio.Lock()
2787
2857
  scoped._runtime_external_payload_transport_cache = None
2788
2858
  scoped._runtime_external_payload_transport_resolved = False
@@ -3599,7 +3669,9 @@ class Client:
3599
3669
  body["build_id"] = build_id
3600
3670
  if compatibility is not None:
3601
3671
  body["compatibility"] = compatibility
3602
- data = await self._request("POST", "/workflows", json=body, context=workflow_id)
3672
+ data = await self._request(
3673
+ "POST", "/workflows", json=self._with_observability_context(body), context=workflow_id,
3674
+ )
3603
3675
  return WorkflowHandle(
3604
3676
  self,
3605
3677
  workflow_id=data["workflow_id"],
@@ -4303,15 +4375,38 @@ class Client:
4303
4375
  return BridgeAdapterOutcome.from_dict(data)
4304
4376
 
4305
4377
  async def signal_workflow(
4306
- self, workflow_id: str, signal_name: str, *, args: list[Any] | None = None
4307
- ) -> None:
4378
+ self,
4379
+ workflow_id: str,
4380
+ signal_name: str,
4381
+ *,
4382
+ args: list[Any] | None = None,
4383
+ request_id: str | None = None,
4384
+ ) -> dict[str, Any]:
4308
4385
  """Deliver an external signal to a running workflow.
4309
4386
 
4310
4387
  Signals are fire-and-forget: the server records the signal in durable
4311
4388
  history and returns immediately. They do not wait for the workflow to
4312
4389
  observe the signal. See the main docs for how to declare the allowed
4313
4390
  signal names on a workflow type.
4391
+
4392
+ ``request_id`` is the caller command key (1-255 opaque, case-sensitive
4393
+ bytes) that makes a retry of this signal safe. When omitted the client
4394
+ generates ``sdk-python-signal-<uuid>`` once per call, so the transport
4395
+ retry policy resends the same key. Resending the same key with the same
4396
+ signal name, arguments and run target returns the original receipt
4397
+ instead of recording a second signal.
4398
+
4399
+ Returns the Server's admission receipt. ``request_id`` is always
4400
+ present (the key that was sent); a Server that retains command keys
4401
+ also reports ``retained`` (``True`` when an earlier admission was
4402
+ returned) and ``intent_verified``. Raises
4403
+ :class:`~durable_workflow.errors.SignalFailed` with
4404
+ ``reason == "request_id_conflict"`` when the key was already retained
4405
+ for a signal with a different intent; the retained command is described
4406
+ in its ``details``. Every exception raised after the key was chosen,
4407
+ including transport errors, carries it as ``request_id``.
4314
4408
  """
4409
+ key = _command_request_id(request_id, "signal")
4315
4410
  body: dict[str, Any] = {}
4316
4411
  if args:
4317
4412
  body["input"] = self._payload_envelope(
@@ -4320,7 +4415,20 @@ class Client:
4320
4415
  workflow_id=workflow_id,
4321
4416
  signal_name=signal_name,
4322
4417
  )
4323
- await self._request("POST", f"/workflows/{workflow_id}/signal/{signal_name}", json=body, context=workflow_id)
4418
+ body["request_id"] = key
4419
+ try:
4420
+ data = await self._request(
4421
+ "POST", f"/workflows/{workflow_id}/signal/{signal_name}",
4422
+ json=self._with_observability_context(body), context=workflow_id,
4423
+ )
4424
+ except Exception as exc:
4425
+ failure = _command_request_failure(exc, command="signal", request_id=key)
4426
+ if failure is exc:
4427
+ raise
4428
+ raise failure from exc
4429
+ receipt: dict[str, Any] = data if isinstance(data, dict) else {}
4430
+ receipt.setdefault("request_id", key)
4431
+ return receipt
4324
4432
 
4325
4433
  async def append_message_stream(
4326
4434
  self,
@@ -4430,7 +4538,10 @@ class Client:
4430
4538
  body: dict[str, Any] = {}
4431
4539
  if reason is not None:
4432
4540
  body["reason"] = reason
4433
- await self._request("POST", f"/workflows/{workflow_id}/cancel", json=body, context=workflow_id)
4541
+ await self._request(
4542
+ "POST", f"/workflows/{workflow_id}/cancel",
4543
+ json=self._with_observability_context(body), context=workflow_id,
4544
+ )
4434
4545
 
4435
4546
  async def terminate_workflow(self, workflow_id: str, *, reason: str | None = None) -> None:
4436
4547
  """Close the current run as terminated immediately.
@@ -4496,15 +4607,28 @@ class Client:
4496
4607
  Validator rejection and validation-routing failures remain typed and
4497
4608
  do not silently fall back to post-accept handler failure.
4498
4609
 
4499
- ``request_id`` lets the caller deduplicate retries. Raises
4500
- :class:`~durable_workflow.errors.UpdateRejected` when the workflow's
4501
- validator rejects the update, or
4610
+ ``request_id`` is the caller command key (1-255 opaque, case-sensitive
4611
+ bytes) that makes a retry of this update safe. When omitted the client
4612
+ generates ``sdk-python-update-<uuid>`` once per call, so the transport
4613
+ retry policy resends the same key. Resending the same key with the same
4614
+ update name, arguments and run target returns the original receipt
4615
+ (``retained`` is ``True`` on a Server that reports it) instead of
4616
+ admitting a second update. The returned response always carries the
4617
+ ``request_id`` that was sent.
4618
+
4619
+ Raises :class:`~durable_workflow.errors.UpdateRejected` when the
4620
+ workflow's validator rejects the update, or
4502
4621
  :class:`~durable_workflow.errors.UpdateValidationFailed` when the
4503
4622
  declared validation boundary cannot be enforced. An accepted update
4504
4623
  whose handler fails raises :class:`~durable_workflow.errors.UpdateFailed`
4505
- with the failure message, Server response and durable identities.
4624
+ with the failure message, Server response and durable identities; the
4625
+ same type with ``reason == "request_id_conflict"`` reports a key that
4626
+ was already retained for an update with a different intent, described
4627
+ in its ``details``. Every exception raised after the key was chosen,
4628
+ including transport errors, carries it as ``request_id``.
4506
4629
  """
4507
4630
  await self._require_update_wait_stage(wait_for or "accepted")
4631
+ key = _command_request_id(request_id, "update")
4508
4632
  body: dict[str, Any] = {}
4509
4633
  if args:
4510
4634
  body["input"] = self._payload_envelope(
@@ -4517,10 +4641,20 @@ class Client:
4517
4641
  body["wait_for"] = wait_for
4518
4642
  if wait_timeout_seconds is not None:
4519
4643
  body["wait_timeout_seconds"] = wait_timeout_seconds
4520
- body["request_id"] = request_id or f"sdk-python-update-{uuid.uuid4().hex}"
4521
- return await self._request(
4522
- "POST", f"/workflows/{workflow_id}/update/{update_name}", json=body, context=workflow_id
4523
- )
4644
+ body["request_id"] = key
4645
+ try:
4646
+ data = await self._request(
4647
+ "POST", f"/workflows/{workflow_id}/update/{update_name}",
4648
+ json=self._with_observability_context(body), context=workflow_id,
4649
+ )
4650
+ except Exception as exc:
4651
+ failure = _command_request_failure(exc, command="update", request_id=key)
4652
+ if failure is exc:
4653
+ raise
4654
+ raise failure from exc
4655
+ if isinstance(data, dict):
4656
+ data.setdefault("request_id", key)
4657
+ return data
4524
4658
 
4525
4659
  async def get_result(
4526
4660
  self,
@@ -5291,7 +5425,11 @@ class Client:
5291
5425
  body: dict[str, Any] = {
5292
5426
  "lease_owner": lease_owner,
5293
5427
  "workflow_task_attempt": workflow_task_attempt,
5294
- "commands": commands,
5428
+ "commands": [
5429
+ self._with_observability_context(command)
5430
+ if command.get("type") in {"start_child_workflow", "continue_as_new"} else command
5431
+ for command in commands
5432
+ ] if self._observability_capability else commands,
5295
5433
  }
5296
5434
  if message_stream_cursors:
5297
5435
  body["message_stream_cursors"] = message_stream_cursors
@@ -412,8 +412,45 @@ class QueryFailed(DurableWorkflowError):
412
412
  return None
413
413
 
414
414
 
415
+ REQUEST_ID_CONFLICT = "request_id_conflict"
416
+ """Reason reported when a command key is reused with a different intent."""
417
+
418
+ _REQUEST_ID_CONFLICT_FIELDS = (
419
+ "request_id",
420
+ "retained_command_id",
421
+ "retained_command_type",
422
+ "retained_outcome",
423
+ "conflict",
424
+ )
425
+
426
+
427
+ def _body_string(body: object, name: str) -> str | None:
428
+ if isinstance(body, dict):
429
+ value = body.get(name)
430
+ if isinstance(value, str) and value:
431
+ return value
432
+ return None
433
+
434
+
435
+ def _request_id_conflict_details(body: object) -> dict[str, Any]:
436
+ """Return the retained-command fields of a ``request_id_conflict`` response body."""
437
+ if not isinstance(body, dict) or body.get("reason") != REQUEST_ID_CONFLICT:
438
+ return {}
439
+ return {name: body[name] for name in _REQUEST_ID_CONFLICT_FIELDS if name in body}
440
+
441
+
415
442
  class SignalFailed(DurableWorkflowError):
416
- """A workflow signal was rejected before it could be delivered."""
443
+ """A workflow signal was rejected before it could be delivered.
444
+
445
+ :attr:`request_id` is the caller command key the client sent with the
446
+ signal (caller-supplied or SDK-generated), so the application can retry
447
+ the same logical signal with the same key. :attr:`reason` is the Server's
448
+ machine-readable refusal reason; ``request_id_conflict`` means the key was
449
+ already retained for a signal with a different intent, and
450
+ :attr:`details` then carries ``retained_command_id``,
451
+ ``retained_command_type``, ``retained_outcome`` and the mismatched
452
+ ``conflict`` parts.
453
+ """
417
454
 
418
455
  def __init__(
419
456
  self,
@@ -422,11 +459,15 @@ class SignalFailed(DurableWorkflowError):
422
459
  reason: str | None = None,
423
460
  status: int | None = None,
424
461
  body: object | None = None,
462
+ request_id: str | None = None,
463
+ details: dict[str, Any] | None = None,
425
464
  ) -> None:
426
465
  super().__init__(message)
427
466
  self.reason = reason
428
467
  self.status = status
429
468
  self.body = body
469
+ self.request_id = request_id if request_id is not None else _body_string(body, "request_id")
470
+ self.details: dict[str, Any] = dict(details) if details is not None else _request_id_conflict_details(body)
430
471
 
431
472
  @property
432
473
  def validation_errors(self) -> dict[str, Any] | None:
@@ -508,17 +549,37 @@ class NonDeterministicReplayError(DurableWorkflowError):
508
549
 
509
550
 
510
551
  class UpdateFailed(InvalidArgument):
511
- """An accepted workflow update failed during handler execution.
552
+ """An accepted workflow update failed, or its command key was refused.
512
553
 
513
554
  ``body`` preserves the Server response, including its durable failure
514
555
  identity. This inherits from :class:`InvalidArgument` to preserve catches
515
556
  written for earlier SDK versions that mapped all HTTP 422 responses there.
557
+
558
+ :attr:`request_id` is the caller command key the client sent with the
559
+ update (caller-supplied or SDK-generated). :attr:`reason` is the Server's
560
+ machine-readable reason when it returned one; ``request_id_conflict``
561
+ means the key was already retained for an update with a different intent,
562
+ and :attr:`details` then carries ``retained_command_id``,
563
+ ``retained_command_type``, ``retained_outcome`` and the mismatched
564
+ ``conflict`` parts.
516
565
  """
517
566
 
518
- def __init__(self, message: str, *, status: int, body: dict[str, Any]) -> None:
567
+ def __init__(
568
+ self,
569
+ message: str,
570
+ *,
571
+ status: int,
572
+ body: dict[str, Any],
573
+ reason: str | None = None,
574
+ request_id: str | None = None,
575
+ details: dict[str, Any] | None = None,
576
+ ) -> None:
519
577
  super().__init__(message)
520
578
  self.status = status
521
579
  self.body = body
580
+ self.reason = reason if reason is not None else self._identity(body, "reason")
581
+ self.request_id = request_id if request_id is not None else self._identity(body, "request_id")
582
+ self.details: dict[str, Any] = dict(details) if details is not None else _request_id_conflict_details(body)
522
583
  self.workflow_id = self._identity(body, "workflow_id")
523
584
  self.run_id = self._identity(body, "run_id")
524
585
  self.update_id = self._identity(body, "update_id")
@@ -909,6 +970,18 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
909
970
  raise UpdateRejected(message or "update rejected", body=body)
910
971
  if reason in update_validation_failure_reasons:
911
972
  raise update_validation_failed("update validation failed")
973
+ if reason == REQUEST_ID_CONFLICT:
974
+ # The retained command type names the key scope; the Server also
975
+ # labels the control-plane operation. Either selects the typed failure.
976
+ command_type = _body_string(body, "retained_command_type") or operation
977
+ if command_type == "signal":
978
+ raise signal_failed("signal request id already retained with a different intent")
979
+ if command_type == "update" and isinstance(body, dict):
980
+ raise UpdateFailed(
981
+ message or "update request id already retained with a different intent",
982
+ status=status,
983
+ body=body,
984
+ )
912
985
  raise ServerError(status, body)
913
986
 
914
987
  if status == 504:
@@ -14,6 +14,9 @@ class WorkflowTaskInterceptorContext:
14
14
  worker_id: str
15
15
  task_queue: str
16
16
  task: dict[str, Any]
17
+ namespace: str | None = None
18
+ observability_supported: bool = False
19
+ defer_observability: bool = False
17
20
 
18
21
 
19
22
  @dataclass(frozen=True)
@@ -25,6 +28,10 @@ class ActivityInterceptorContext:
25
28
  task: dict[str, Any]
26
29
  activity_type: str
27
30
  args: tuple[Any, ...]
31
+ namespace: str | None = None
32
+ observability_supported: bool = False
33
+ remote: bool = False
34
+ execution_context: dict[str, Any] | None = None
28
35
 
29
36
 
30
37
  @dataclass(frozen=True)
@@ -34,6 +41,8 @@ class QueryTaskInterceptorContext:
34
41
  worker_id: str
35
42
  task_queue: str
36
43
  task: dict[str, Any]
44
+ namespace: str | None = None
45
+ observability_supported: bool = False
37
46
 
38
47
 
39
48
  WorkflowTaskHandler = Callable[[WorkflowTaskInterceptorContext], Awaitable[list[dict[str, Any]] | None]]
@@ -3,7 +3,8 @@
3
3
  .. warning::
4
4
 
5
5
  :class:`TransportRetryPolicy` covers **only client-side HTTP retries** for
6
- transient transport errors (connection failures, timeouts, 5xx responses,
6
+ transient transport errors (connection failures, timeouts, a server or
7
+ proxy that disconnects before sending a response, 5xx responses,
7
8
  429 rate-limiting). It is **not** the activity retry policy. Activity-level
8
9
  retry and timeout configuration lives on
9
10
  :class:`durable_workflow.workflow.ActivityRetryPolicy` and is passed to
@@ -201,9 +202,16 @@ class TransportRetryPolicy:
201
202
  """
202
203
  Retry policy for transient HTTP transport errors.
203
204
 
204
- Retries requests that fail with transient errors (connection errors,
205
- timeouts, 5xx server errors, 429 rate limit). Does not retry client
206
- errors (4xx except 429).
205
+ Retries requests that fail with transient errors: ``httpx.NetworkError``
206
+ (including ``ConnectError``), ``httpx.TimeoutException``,
207
+ ``httpx.RemoteProtocolError`` (the server or a proxy closed the connection
208
+ before sending a response), 5xx server errors and 429 rate limit. Does not
209
+ retry client errors (4xx except 429) or ``httpx.LocalProtocolError``.
210
+
211
+ A retried request is resent byte-identical. Signals and updates carry a
212
+ durable ``request_id`` per logical call, so a retry after a lost response
213
+ is answered from the retained receipt instead of admitting a second
214
+ command.
207
215
 
208
216
  This policy runs inside :class:`~durable_workflow.Client` around HTTP
209
217
  requests. It does not retry workflow runs, workflow tasks, activity
@@ -228,8 +236,12 @@ class TransportRetryPolicy:
228
236
  if attempt >= self.max_attempts:
229
237
  return False
230
238
 
231
- # Retry connection errors and timeouts
232
- if isinstance(exc, httpx.ConnectError | httpx.TimeoutException | httpx.NetworkError):
239
+ # Retry connection errors, timeouts and a peer that disconnected before
240
+ # answering (a lost response is recovered through the request's identity).
241
+ if isinstance(
242
+ exc,
243
+ httpx.ConnectError | httpx.TimeoutException | httpx.NetworkError | httpx.RemoteProtocolError,
244
+ ):
233
245
  return True
234
246
 
235
247
  # Retry 5xx server errors and 429 rate limit
@@ -118,8 +118,17 @@ class SyncWorkflowHandle:
118
118
  result: WorkflowRun = self._runner.run(self._handle.describe_run(run_id))
119
119
  return result
120
120
 
121
- def signal(self, signal_name: str, args: list[Any] | None = None) -> None:
122
- self._runner.run(self._handle.signal(signal_name, args=args))
121
+ def signal(
122
+ self,
123
+ signal_name: str,
124
+ args: list[Any] | None = None,
125
+ *,
126
+ request_id: str | None = None,
127
+ ) -> dict[str, Any]:
128
+ result: dict[str, Any] = self._runner.run(
129
+ self._handle.signal(signal_name, args=args, request_id=request_id)
130
+ )
131
+ return result
123
132
 
124
133
  def query(self, query_name: str, args: list[Any] | None = None) -> Any:
125
134
  return self._runner.run(self._handle.query(query_name, args=args))
@@ -513,8 +522,18 @@ class Client:
513
522
  result: WorkflowRun = self._runner.run(self._async.describe_workflow_run(workflow_id, run_id))
514
523
  return result
515
524
 
516
- def signal_workflow(self, workflow_id: str, signal_name: str, *, args: list[Any] | None = None) -> None:
517
- self._runner.run(self._async.signal_workflow(workflow_id, signal_name, args=args))
525
+ def signal_workflow(
526
+ self,
527
+ workflow_id: str,
528
+ signal_name: str,
529
+ *,
530
+ args: list[Any] | None = None,
531
+ request_id: str | None = None,
532
+ ) -> dict[str, Any]:
533
+ result: dict[str, Any] = self._runner.run(
534
+ self._async.signal_workflow(workflow_id, signal_name, args=args, request_id=request_id)
535
+ )
536
+ return result
518
537
 
519
538
  def query_workflow(self, workflow_id: str, query_name: str, *, args: list[Any] | None = None) -> Any:
520
539
  return self._runner.run(self._async.query_workflow(workflow_id, query_name, args=args))