durable-workflow 2.5.10__tar.gz → 2.5.12__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 (130) hide show
  1. {durable_workflow-2.5.10/src/durable_workflow.egg-info → durable_workflow-2.5.12}/PKG-INFO +9 -1
  2. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/README.md +8 -0
  3. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/pyproject.toml +3 -3
  4. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/client.py +3 -1
  5. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/replay_conformance.py +35 -16
  6. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/retry_policy.py +22 -1
  7. {durable_workflow-2.5.10 → durable_workflow-2.5.12/src/durable_workflow.egg-info}/PKG-INFO +9 -1
  8. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  9. durable_workflow-2.5.12/tests/test_query_timeout_retry.py +73 -0
  10. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_replay_conformance.py +44 -3
  11. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_replay_regression_corpus.py +26 -0
  12. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/LICENSE +0 -0
  13. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/setup.cfg +0 -0
  14. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/__init__.py +0 -0
  15. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/_activity_process.py +0 -0
  16. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/_cancellation_scope.py +0 -0
  18. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  19. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  20. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/_prepared_local_activity.py +0 -0
  21. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  22. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/activity.py +0 -0
  23. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/auth_composition.py +0 -0
  24. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/cancellation.py +0 -0
  25. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/errors.py +0 -0
  26. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/external_storage.py +0 -0
  27. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/external_task_input.py +0 -0
  28. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/external_task_result.py +0 -0
  29. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/history_bundle_verify.py +0 -0
  30. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/interceptors.py +0 -0
  31. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/invocable.py +0 -0
  32. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/metrics.py +0 -0
  33. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/nexus.py +0 -0
  34. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/py.typed +0 -0
  35. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/python_conformance.py +0 -0
  36. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/replay_verify.py +0 -0
  37. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/serializer.py +0 -0
  38. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/sync.py +0 -0
  39. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/testing.py +0 -0
  40. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/worker.py +0 -0
  41. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/worker_session.py +0 -0
  42. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/workflow.py +0 -0
  43. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  44. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  45. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  46. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow.egg-info/requires.txt +0 -0
  47. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/src/durable_workflow.egg-info/top_level.txt +0 -0
  48. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_activity_cancellation_policies.py +0 -0
  49. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_activity_context.py +0 -0
  50. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_activity_process.py +0 -0
  51. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_auth_composition.py +0 -0
  52. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_avro_value_benchmark.py +0 -0
  53. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_avro_value_protocol.py +0 -0
  54. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cancellation_context.py +0 -0
  55. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cancellation_remaining_time.py +0 -0
  56. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cancellation_scope_admission.py +0 -0
  57. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cancellation_scope_authoring.py +0 -0
  58. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cancellation_scope_boundary.py +0 -0
  59. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cancellation_scope_opening.py +0 -0
  60. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_child_workflow_policies.py +0 -0
  61. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_ci_checkout.py +0 -0
  62. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_ci_integration_endpoint.py +0 -0
  63. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_client.py +0 -0
  64. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_client_timeouts.py +0 -0
  65. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_codec_regression_corpus.py +0 -0
  66. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  67. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_committed_cancellation_scope_groups.py +0 -0
  68. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_committed_cancellation_scope_history.py +0 -0
  69. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_committed_cancellation_scope_replay.py +0 -0
  70. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_condition_replay_drift.py +0 -0
  71. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_control_plane_parity_fixtures.py +0 -0
  72. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cooperative_cancellation.py +0 -0
  73. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cooperative_cancellation_client.py +0 -0
  74. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cooperative_cancellation_worker.py +0 -0
  75. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_cooperative_remote_worker.py +0 -0
  76. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_docstring_cross_references.py +0 -0
  77. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_durable_selection.py +0 -0
  78. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_errors.py +0 -0
  79. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_external_storage.py +0 -0
  80. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_external_task_input.py +0 -0
  81. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_external_task_result.py +0 -0
  82. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_golden_history_replay.py +0 -0
  83. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_history_bundle_verify.py +0 -0
  84. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_history_event_contract.py +0 -0
  85. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_invocable.py +0 -0
  86. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_message_streams.py +0 -0
  87. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_metrics.py +0 -0
  88. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_nexus_namespace.py +0 -0
  89. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_nexus_wire.py +0 -0
  90. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_order_processing_example.py +0 -0
  91. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_parallel_completion_order_replay.py +0 -0
  92. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_parallel_saga_authoring.py +0 -0
  93. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_patch_history.py +0 -0
  94. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_pending_operation_replay.py +0 -0
  95. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_prepared_local_activity_client.py +0 -0
  96. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_prepared_local_activity_groups.py +0 -0
  97. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_prepared_local_activity_policies.py +0 -0
  98. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_prepared_local_activity_worker.py +0 -0
  99. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_public_boundary_scanner.py +0 -0
  100. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_pypi_project_surface.py +0 -0
  101. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_python_conformance.py +0 -0
  102. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_queries.py +0 -0
  103. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_readme_quickstart.py +0 -0
  104. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_redrive_replay.py +0 -0
  105. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_registration_ambiguity.py +0 -0
  106. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_release_metadata.py +0 -0
  107. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_replay.py +0 -0
  108. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_replay_verify.py +0 -0
  109. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_retry_policy.py +0 -0
  110. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_run_cancellation_scope_replay.py +0 -0
  111. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_runtime_external_payload_transport.py +0 -0
  112. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_schedules.py +0 -0
  113. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_scoped_run_cancellation_context.py +0 -0
  114. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_serializer.py +0 -0
  115. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_signals.py +0 -0
  116. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_sleep.py +0 -0
  117. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_standalone_activity_client.py +0 -0
  118. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_sticky_execution.py +0 -0
  119. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_storage_admission.py +0 -0
  120. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_sync.py +0 -0
  121. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_testing_harness.py +0 -0
  122. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_update_signal_condition_replay.py +0 -0
  123. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_updates.py +0 -0
  124. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_wait_condition.py +0 -0
  125. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_worker.py +0 -0
  126. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_worker_session.py +0 -0
  127. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_workflow_entrypoint_validation.py +0 -0
  128. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_workflow_result_timeout.py +0 -0
  129. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/tests/test_workflow_streams.py +0 -0
  130. {durable_workflow-2.5.10 → durable_workflow-2.5.12}/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.5.10
3
+ Version: 2.5.12
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
@@ -175,6 +175,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
175
175
 
176
176
  ## Development
177
177
 
178
+ The installed `durable-workflow-replay-conformance` command runs an offline
179
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
180
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
181
+ The report records that installed identity separately from the declared platform
182
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
183
+ does not prove installation or execution of the other declared artifacts; the
184
+ aggregate runner supplies its own verified installation receipts.
185
+
178
186
  ```bash
179
187
  pip install -e '.[dev]'
180
188
  ruff check src/ tests/
@@ -135,6 +135,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
135
135
 
136
136
  ## Development
137
137
 
138
+ The installed `durable-workflow-replay-conformance` command runs an offline
139
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
140
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
141
+ The report records that installed identity separately from the declared platform
142
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
143
+ does not prove installation or execution of the other declared artifacts; the
144
+ aggregate runner supplies its own verified installation receipts.
145
+
138
146
  ```bash
139
147
  pip install -e '.[dev]'
140
148
  ruff check src/ tests/
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.5.10"
7
+ version = "2.5.12"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.5.10"
75
- registry-version = "2.5.10"
74
+ product-train = "2.5.12"
75
+ registry-version = "2.5.12"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.20"
78
78
  control-plane-version = "2"
@@ -4323,7 +4323,9 @@ class Client:
4323
4323
  Queries are synchronous and non-mutating. The server runs the named
4324
4324
  query handler inside the workflow process and returns the decoded
4325
4325
  result. Raises :class:`~durable_workflow.errors.QueryFailed` if the
4326
- query was rejected or the handler errored.
4326
+ query was rejected, the handler errored, or the original query timed
4327
+ out. A canonical query timeout is surfaced without automatically
4328
+ admitting another query.
4327
4329
  """
4328
4330
  await self._require_query_support()
4329
4331
  body: dict[str, Any] = {}
@@ -15,6 +15,7 @@ import json
15
15
  import sys
16
16
  from collections.abc import Mapping, Sequence
17
17
  from datetime import datetime, timezone
18
+ from importlib import metadata
18
19
  from pathlib import Path
19
20
  from typing import Any
20
21
 
@@ -204,6 +205,17 @@ def compose_report(
204
205
  started_at = _timestamp()
205
206
  versions = _canonical_artifact_metadata(dict(artifact_versions or {}))
206
207
  sources = _canonical_artifact_metadata(dict(artifact_sources or {}))
208
+ try:
209
+ installed_version = metadata.version("durable-workflow")
210
+ except metadata.PackageNotFoundError:
211
+ installed_version = None
212
+ identity = {
213
+ "runtime": "sdk-python",
214
+ "package": "durable-workflow",
215
+ "version": installed_version,
216
+ "version_source": "installed_distribution_metadata" if installed_version else "unavailable",
217
+ "declared_version": _artifact_metadata(versions, "sdk-python"),
218
+ }
207
219
  scenario_results: dict[str, dict[str, Any]] = {}
208
220
  findings: list[dict[str, Any]] = []
209
221
  finding_links: dict[str, list[dict[str, Any]]] = {}
@@ -212,7 +224,7 @@ def compose_report(
212
224
  scenario_results,
213
225
  findings,
214
226
  finding_links,
215
- _published_artifact_scenario(versions, sources),
227
+ _published_artifact_scenario(versions, sources, installed_version),
216
228
  )
217
229
 
218
230
  for family, scenario_id in COMPLETED_SCENARIOS.items():
@@ -242,6 +254,9 @@ def compose_report(
242
254
  has_failures = any(
243
255
  scenario.get("status") != "pass" for scenario in scenario_results.values()
244
256
  )
257
+ for scenario in scenario_results.values():
258
+ scenario["implementation_identity"] = dict(identity)
259
+ scenario["artifact_tuple_evidence"] = "declared"
245
260
 
246
261
  return {
247
262
  "schema": RESULT_SCHEMA,
@@ -252,6 +267,8 @@ def compose_report(
252
267
  "finished_at": _timestamp(),
253
268
  "artifact_versions": versions,
254
269
  "artifact_sources": sources,
270
+ "artifact_tuple_evidence": "declared",
271
+ "implementation_identity": identity,
255
272
  "runtime_matrix": {
256
273
  "runtimes": ["sdk-python"],
257
274
  },
@@ -307,6 +324,7 @@ def _append_scenario(
307
324
  def _published_artifact_scenario(
308
325
  artifact_versions: Mapping[str, str],
309
326
  artifact_sources: Mapping[str, str],
327
+ installed_version: str | None,
310
328
  ) -> dict[str, Any]:
311
329
  missing_versions: list[str] = []
312
330
  missing_sources: list[str] = []
@@ -338,14 +356,14 @@ def _published_artifact_scenario(
338
356
  untrusted_sources[artifact] = source
339
357
 
340
358
  missing = sorted(set(missing_versions + missing_sources))
341
- status = (
342
- "pass"
343
- if not missing
344
- and not rejected_versions
345
- and not forbidden_sources
346
- and not untrusted_sources
347
- else "fail"
348
- )
359
+ declarations_valid = not missing and not rejected_versions and not forbidden_sources and not untrusted_sources
360
+ declared_version = _artifact_metadata(artifact_versions, "sdk-python")
361
+ identity_error = None
362
+ if not installed_version:
363
+ identity_error = "installed_sdk_metadata_missing"
364
+ elif declared_version != installed_version:
365
+ identity_error = "installed_sdk_version_mismatch"
366
+ status = "pass" if declarations_valid and identity_error is None else "fail"
349
367
 
350
368
  return _scenario(
351
369
  "published_artifact_install_only",
@@ -361,8 +379,14 @@ def _published_artifact_scenario(
361
379
  "rejected_versions": rejected_versions,
362
380
  "forbidden_sources": forbidden_sources,
363
381
  "untrusted_sources": untrusted_sources,
364
- "published_artifacts_only": status == "pass",
365
- "published_install_tuple_proven": status == "pass",
382
+ "declared_artifacts_published_only": bool(declarations_valid),
383
+ "declared_sdk_version": declared_version,
384
+ "installed_sdk_version": installed_version,
385
+ "sdk_identity_error": identity_error,
386
+ "sdk_version_matches": bool(installed_version and declared_version == installed_version),
387
+ "artifact_tuple_evidence": "declared",
388
+ "published_artifacts_only": False,
389
+ "published_install_tuple_proven": False,
366
390
  },
367
391
  )
368
392
 
@@ -589,11 +613,6 @@ def _scenario(
589
613
  "scenario_id": scenario_id,
590
614
  "status": status,
591
615
  "published_artifact_versions": dict(artifact_versions),
592
- "implementation_identity": {
593
- "runtime": "sdk-python",
594
- "package": "durable-workflow",
595
- "version": _artifact_metadata(artifact_versions, "sdk-python"),
596
- },
597
616
  "runtime_matrix": {
598
617
  "runtimes": ["sdk-python"],
599
618
  },
@@ -17,6 +17,7 @@ import contextvars
17
17
  import json
18
18
  import logging
19
19
  import random
20
+ import re
20
21
  from collections.abc import Awaitable, Callable
21
22
  from dataclasses import dataclass
22
23
  from typing import TypeVar
@@ -34,6 +35,24 @@ _worker_storage_admission_stop: contextvars.ContextVar[Callable[[], bool] | None
34
35
  )
35
36
 
36
37
 
38
+ def _query_timeout_refusal(exc: httpx.HTTPStatusError) -> bool:
39
+ request = exc.request
40
+ if (exc.response.status_code != 504 or request.method != "POST"
41
+ or request.headers.get("X-Durable-Workflow-Control-Plane-Version") != "2"
42
+ or re.search(r"/api/workflows/[^/]+/query/[^/]+$", request.url.path) is None):
43
+ return False
44
+ try:
45
+ body = exc.response.json()
46
+ except ValueError:
47
+ return False
48
+ # This response ends an admitted query. Another POST creates a new task,
49
+ # rather than retrying the original task or its remaining caller budget.
50
+ reason = body.get("reason") if isinstance(body, dict) else None
51
+ return isinstance(reason, str) and reason in {
52
+ "query_worker_timeout", "query_worker_execution_timeout", "query_task_not_claimed",
53
+ }
54
+
55
+
37
56
  def _storage_refusal(exc: Exception) -> tuple[ServerError, str | None] | None:
38
57
  if not isinstance(exc, httpx.HTTPStatusError):
39
58
  return None
@@ -194,6 +213,8 @@ class TransportRetryPolicy:
194
213
  :class:`durable_workflow.workflow.ChildWorkflowRetryPolicy`.
195
214
 
196
215
  Uses exponential backoff with jitter to avoid thundering herd.
216
+ Canonical query timeouts end the original query and are surfaced without
217
+ another query admission; unrelated transient HTTP failures still retry.
197
218
  """
198
219
 
199
220
  max_attempts: int = 3
@@ -213,7 +234,7 @@ class TransportRetryPolicy:
213
234
 
214
235
  # Retry 5xx server errors and 429 rate limit
215
236
  if isinstance(exc, httpx.HTTPStatusError):
216
- if _poll_capacity_refusal(exc):
237
+ if _query_timeout_refusal(exc) or _poll_capacity_refusal(exc):
217
238
  return False
218
239
  return exc.response.status_code >= 500 or exc.response.status_code == 429
219
240
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.10
3
+ Version: 2.5.12
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
@@ -175,6 +175,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
175
175
 
176
176
  ## Development
177
177
 
178
+ The installed `durable-workflow-replay-conformance` command runs an offline
179
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
180
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
181
+ The report records that installed identity separately from the declared platform
182
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
183
+ does not prove installation or execution of the other declared artifacts; the
184
+ aggregate runner supplies its own verified installation receipts.
185
+
178
186
  ```bash
179
187
  pip install -e '.[dev]'
180
188
  ruff check src/ tests/
@@ -95,6 +95,7 @@ tests/test_public_boundary_scanner.py
95
95
  tests/test_pypi_project_surface.py
96
96
  tests/test_python_conformance.py
97
97
  tests/test_queries.py
98
+ tests/test_query_timeout_retry.py
98
99
  tests/test_readme_quickstart.py
99
100
  tests/test_redrive_replay.py
100
101
  tests/test_registration_ambiguity.py
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from durable_workflow import Client
7
+ from durable_workflow.errors import QueryFailed
8
+ from durable_workflow.retry_policy import TransportRetryPolicy
9
+
10
+
11
+ @pytest.mark.parametrize("protocol", ["1.20", "1.19"])
12
+ @pytest.mark.parametrize("reason", [
13
+ "query_worker_execution_timeout", "query_task_not_claimed", "query_worker_timeout",
14
+ ])
15
+ @pytest.mark.parametrize("prefix", ["", "/api/runtime/v1/namespaces/example"])
16
+ async def test_query_exposes_original_canonical_timeout_without_another_admission(
17
+ monkeypatch: pytest.MonkeyPatch, protocol: str, reason: str, prefix: str,
18
+ ) -> None:
19
+ monkeypatch.setenv("DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION", protocol)
20
+ original = {"reason": reason, "message": "Original query ended.",
21
+ "control_plane": {"operation": "query", "reason": reason}}
22
+ admissions = 0
23
+ discovery_protocols = []
24
+
25
+ def handle(request: httpx.Request) -> httpx.Response:
26
+ nonlocal admissions
27
+ if request.url.path.endswith("/api/cluster/info"):
28
+ discovery_protocols.append(request.headers["X-Durable-Workflow-Protocol-Version"])
29
+ return httpx.Response(200, json={
30
+ "worker_protocol": {"server_capabilities": {"query_tasks": True}},
31
+ })
32
+ assert request.url.path == prefix + "/api/workflows/original/query/total"
33
+ assert request.headers["X-Durable-Workflow-Control-Plane-Version"] == "2"
34
+ admissions += 1
35
+ if admissions == 1:
36
+ return httpx.Response(504, json=original)
37
+ # The old policy hides the original failure behind a distinct admission.
38
+ return httpx.Response(200, json={"result": "replacement-query"})
39
+
40
+ async with Client("https://runtime.example" + prefix, worker_token="worker", control_token="control",
41
+ retry_policy=TransportRetryPolicy(initial_backoff_seconds=0, jitter=False)) as client:
42
+ await client._http.aclose()
43
+ client._http = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handle))
44
+ with pytest.raises(QueryFailed) as failure:
45
+ await client.query_workflow("original", "total")
46
+ assert admissions == 1
47
+ assert discovery_protocols == [protocol]
48
+ assert failure.value.status == 504 and failure.value.reason == reason
49
+ assert failure.value.body == original
50
+
51
+
52
+ @pytest.mark.parametrize(("method", "path", "status", "body", "headers"), [
53
+ ("POST", "/api/workflows/w/query/q", 504, {"reason": "gateway_timeout"},
54
+ {"X-Durable-Workflow-Control-Plane-Version": "2"}),
55
+ ("POST", "/api/workflows/w/query/q", 503, {"reason": "query_worker_timeout"},
56
+ {"X-Durable-Workflow-Control-Plane-Version": "2"}),
57
+ ("GET", "/api/workflows/w/query/q", 504, {"reason": "query_worker_timeout"},
58
+ {"X-Durable-Workflow-Control-Plane-Version": "2"}),
59
+ ("POST", "/api/workflows/w/signals/q", 504, {"reason": "query_worker_timeout"},
60
+ {"X-Durable-Workflow-Control-Plane-Version": "2"}),
61
+ ("POST", "/api/workflows/w/query/q", 504, {"reason": "query_worker_timeout"}, {}),
62
+ ("POST", "/api/workflows/w/query/q", 504, [],
63
+ {"X-Durable-Workflow-Control-Plane-Version": "2"}),
64
+ ("POST", "/api/workflows/w/query/q", 504, {"reason": []},
65
+ {"X-Durable-Workflow-Control-Plane-Version": "2"}),
66
+ ])
67
+ def test_other_transport_failures_keep_existing_retry_policy(
68
+ method: str, path: str, status: int, body: object, headers: dict[str, str],
69
+ ) -> None:
70
+ request = httpx.Request(method, "https://runtime.example" + path, headers=headers)
71
+ response = httpx.Response(status, request=request, json=body)
72
+ error = httpx.HTTPStatusError("transport failure", request=request, response=response)
73
+ assert TransportRetryPolicy().should_retry(error, 0) is True
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from importlib import metadata
4
5
  from pathlib import Path
5
6
  from typing import Any
6
7
 
8
+ import pytest
9
+
7
10
  from durable_workflow.replay_conformance import (
8
11
  COVERAGE_SCOPE,
9
12
  RESULT_SCHEMA,
@@ -25,6 +28,11 @@ def artifact_versions() -> dict[str, str]:
25
28
  }
26
29
 
27
30
 
31
+ @pytest.fixture(autouse=True)
32
+ def installed_sdk_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
33
+ monkeypatch.setattr(metadata, "version", lambda package: "0.4.73")
34
+
35
+
28
36
  def artifact_sources() -> dict[str, str]:
29
37
  return {
30
38
  "server": "docker_image",
@@ -81,7 +89,7 @@ def test_compose_report_emits_python_replay_conformance_shard() -> None:
81
89
  assert indexed[scenario_id]["status"] == "pass"
82
90
 
83
91
 
84
- def test_report_proves_published_artifact_install_policy() -> None:
92
+ def test_report_validates_declared_tuple_without_claiming_installation_proof() -> None:
85
93
  report = compose_report(
86
94
  artifact_versions=artifact_versions(),
87
95
  artifact_sources=artifact_sources(),
@@ -90,13 +98,46 @@ def test_report_proves_published_artifact_install_policy() -> None:
90
98
  observed = published["observed_outputs"]
91
99
 
92
100
  assert published["status"] == "pass"
93
- assert observed["published_artifacts_only"] is True
94
- assert observed["published_install_tuple_proven"] is True
101
+ assert observed["published_artifacts_only"] is False
102
+ assert observed["published_install_tuple_proven"] is False
103
+ assert observed["declared_artifacts_published_only"] is True
104
+ assert observed["artifact_tuple_evidence"] == "declared"
105
+ assert observed["installed_sdk_version"] == "0.4.73"
106
+ assert observed["sdk_version_matches"] is True
107
+ assert observed["sdk_identity_error"] is None
108
+ assert report["implementation_identity"]["version"] == "0.4.73"
95
109
  assert observed["missing_artifacts"] == []
96
110
  assert observed["artifact_sources"]["server"] == "docker_image"
97
111
  assert observed["artifact_sources"]["sdk-python"] == "pypi_package"
98
112
 
99
113
 
114
+ def test_report_rejects_declared_sdk_version_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
115
+ monkeypatch.setattr(metadata, "version", lambda package: "2.5.10")
116
+ report = compose_report(artifact_versions=artifact_versions(), artifact_sources=artifact_sources())
117
+ install = scenarios(report)["published_artifact_install_only"]
118
+ assert report["outcome"] == "fail"
119
+ assert install["status"] == "fail"
120
+ assert install["observed_outputs"]["installed_sdk_version"] == "2.5.10"
121
+ assert install["observed_outputs"]["declared_sdk_version"] == "0.4.73"
122
+ assert install["observed_outputs"]["sdk_identity_error"] == "installed_sdk_version_mismatch"
123
+ assert install["observed_outputs"]["published_install_tuple_proven"] is False
124
+ assert all(scenario["implementation_identity"]["version"] == "2.5.10" for scenario in report["scenario_results"])
125
+ assert report["finding_links"]["published_artifact_install_only"]
126
+
127
+
128
+ def test_report_rejects_missing_installed_sdk_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
129
+ def missing_metadata(package: str) -> str:
130
+ raise metadata.PackageNotFoundError(package)
131
+
132
+ monkeypatch.setattr(metadata, "version", missing_metadata)
133
+ report = compose_report(artifact_versions=artifact_versions(), artifact_sources=artifact_sources())
134
+ install = scenarios(report)["published_artifact_install_only"]
135
+ assert report["outcome"] == "fail"
136
+ assert install["observed_outputs"]["sdk_identity_error"] == "installed_sdk_metadata_missing"
137
+ assert install["implementation_identity"]["version"] is None
138
+ assert install["observed_outputs"]["published_install_tuple_proven"] is False
139
+
140
+
100
141
  def test_report_rejects_missing_versions_and_sources() -> None:
101
142
  report = compose_report(
102
143
  artifact_versions={"sdk-python": "0.4.73"},
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from collections.abc import Mapping, Sequence
5
+ from importlib import metadata
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
  from unittest.mock import AsyncMock
@@ -11,6 +12,7 @@ import pytest
11
12
  from durable_workflow import Replayer, Worker, serializer, workflow
12
13
  from durable_workflow.client import PROTOCOL_VERSION, Client, WorkflowExecution, WorkflowStreamAppendItem
13
14
  from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled, WorkflowPayloadDecodeError
15
+ from durable_workflow.replay_conformance import compose_report
14
16
  from durable_workflow.workflow import (
15
17
  LocalActivityExecutionAborted,
16
18
  WorkflowContext,
@@ -371,6 +373,27 @@ def _execute_fixture(fixture: dict[str, Any]) -> list[dict[str, Any]]:
371
373
  assert isinstance(workflow, dict)
372
374
  workflow_type = workflow.get("type")
373
375
  assert isinstance(workflow_type, str) and workflow_type
376
+ if workflow_type == "sdk.replay-conformance-report":
377
+ # This public command executes the SDK's offline replay scenarios. Its
378
+ # regression input is artifact declarations, not an invented history.
379
+ assert "history" not in fixture
380
+ inputs = workflow.get("input")
381
+ assert isinstance(inputs, list) and len(inputs) == 1 and isinstance(inputs[0], dict)
382
+ report = compose_report(**inputs[0])
383
+ install = next(scenario for scenario in report["scenario_results"]
384
+ if scenario["scenario_id"] == "published_artifact_install_only")
385
+ observed = install["observed_outputs"]
386
+ commands = [{
387
+ "type": "replay_conformance_report",
388
+ "outcome": report["outcome"],
389
+ "sdk_identity_error": observed.get("sdk_identity_error"),
390
+ "declared_sdk_version": observed.get("declared_sdk_version"),
391
+ "published_install_tuple_proven": observed["published_install_tuple_proven"],
392
+ }]
393
+ _assert_matches(fixture["command_sequence"], commands, f"{fixture['id']}.command_sequence")
394
+ _assert_matches(fixture["expected"], {"command_sequence": commands}, f"{fixture['id']}.expected")
395
+ assert report["implementation_identity"]["version"] == metadata.version("durable-workflow")
396
+ return commands
374
397
  assert workflow_type in WORKFLOW_TYPES, (
375
398
  f"replay fixture workflow {workflow_type!r} has no Python implementation; "
376
399
  "register its reproducer workflow in WORKFLOWS"
@@ -462,6 +485,9 @@ def test_checked_in_replay_regression_corpus_uses_official_replayer(
462
485
  assert isinstance(workflow, dict)
463
486
  expected = fixture.get("expected")
464
487
  assert isinstance(expected, dict) and expected
488
+ if workflow.get("type") == "sdk.replay-conformance-report":
489
+ _execute_fixture(fixture)
490
+ return
465
491
  expected_error = expected.get("error")
466
492
  expected_replay_error = fixture.get("expected_replay_error")
467
493
  if isinstance(expected_replay_error, dict):