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.
- {durable_workflow-2.6.2/src/durable_workflow.egg-info → durable_workflow-2.7.0}/PKG-INFO +4 -1
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/pyproject.toml +7 -3
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_activity_process.py +6 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/client.py +154 -16
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/errors.py +76 -3
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/interceptors.py +9 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/retry_policy.py +18 -6
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/sync.py +23 -4
- durable_workflow-2.7.0/src/durable_workflow/telemetry.py +341 -0
- durable_workflow-2.7.0/src/durable_workflow/telemetry_carrier.py +223 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/worker.py +80 -4
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/workflow_updates_conformance.py +66 -8
- {durable_workflow-2.6.2 → durable_workflow-2.7.0/src/durable_workflow.egg-info}/PKG-INFO +4 -1
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/SOURCES.txt +6 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/requires.txt +4 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_client.py +246 -3
- durable_workflow-2.7.0/tests/test_client_telemetry.py +93 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_errors.py +73 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_regression_corpus.py +62 -1
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_retry_policy.py +12 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sync.py +25 -3
- durable_workflow-2.7.0/tests/test_telemetry.py +293 -0
- durable_workflow-2.7.0/tests/test_telemetry_carrier.py +158 -0
- durable_workflow-2.7.0/tests/test_worker_telemetry_wire.py +205 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_updates_conformance.py +10 -1
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/LICENSE +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/README.md +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/setup.cfg +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_query_task_authority.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_worker.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_worker_observation.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.6.2 → durable_workflow-2.7.0}/tests/test_workflow_result_timeout.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
75
|
-
registry-version = "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(
|
|
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(
|
|
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(
|
|
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,
|
|
4307
|
-
|
|
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
|
-
|
|
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(
|
|
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``
|
|
4500
|
-
|
|
4501
|
-
|
|
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"] =
|
|
4521
|
-
|
|
4522
|
-
|
|
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":
|
|
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
|
|
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__(
|
|
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,
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
232
|
-
|
|
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(
|
|
122
|
-
self
|
|
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(
|
|
517
|
-
self
|
|
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))
|