durable-workflow 2.5.11__tar.gz → 2.5.13__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 (131) hide show
  1. {durable_workflow-2.5.11/src/durable_workflow.egg-info → durable_workflow-2.5.13}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/client.py +3 -1
  4. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/retry_policy.py +22 -1
  5. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/workflow.py +27 -6
  6. {durable_workflow-2.5.11 → durable_workflow-2.5.13/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  8. durable_workflow-2.5.13/tests/test_query_timeout_retry.py +73 -0
  9. durable_workflow-2.5.13/tests/test_replay_clock.py +93 -0
  10. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay_regression_corpus.py +2 -0
  11. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/LICENSE +0 -0
  12. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/README.md +0 -0
  13. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/setup.cfg +0 -0
  14. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/__init__.py +0 -0
  15. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_activity_process.py +0 -0
  16. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_cancellation_scope.py +0 -0
  18. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  19. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  20. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_prepared_local_activity.py +0 -0
  21. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  22. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/activity.py +0 -0
  23. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/auth_composition.py +0 -0
  24. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/cancellation.py +0 -0
  25. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/errors.py +0 -0
  26. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_storage.py +0 -0
  27. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_task_input.py +0 -0
  28. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/external_task_result.py +0 -0
  29. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/history_bundle_verify.py +0 -0
  30. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/interceptors.py +0 -0
  31. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/invocable.py +0 -0
  32. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/metrics.py +0 -0
  33. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/nexus.py +0 -0
  34. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/py.typed +0 -0
  35. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/python_conformance.py +0 -0
  36. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/replay_conformance.py +0 -0
  37. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/replay_verify.py +0 -0
  38. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/serializer.py +0 -0
  39. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/sync.py +0 -0
  40. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/testing.py +0 -0
  41. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/worker.py +0 -0
  42. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/worker_session.py +0 -0
  43. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  44. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  45. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  46. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/requires.txt +0 -0
  47. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/src/durable_workflow.egg-info/top_level.txt +0 -0
  48. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_activity_cancellation_policies.py +0 -0
  49. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_activity_context.py +0 -0
  50. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_activity_process.py +0 -0
  51. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_auth_composition.py +0 -0
  52. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_avro_value_benchmark.py +0 -0
  53. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_avro_value_protocol.py +0 -0
  54. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_context.py +0 -0
  55. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_remaining_time.py +0 -0
  56. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_admission.py +0 -0
  57. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_authoring.py +0 -0
  58. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_boundary.py +0 -0
  59. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cancellation_scope_opening.py +0 -0
  60. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_child_workflow_policies.py +0 -0
  61. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_ci_checkout.py +0 -0
  62. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_ci_integration_endpoint.py +0 -0
  63. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_client.py +0 -0
  64. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_client_timeouts.py +0 -0
  65. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_codec_regression_corpus.py +0 -0
  66. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  67. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_groups.py +0 -0
  68. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_history.py +0 -0
  69. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_committed_cancellation_scope_replay.py +0 -0
  70. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_condition_replay_drift.py +0 -0
  71. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_control_plane_parity_fixtures.py +0 -0
  72. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation.py +0 -0
  73. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation_client.py +0 -0
  74. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_cancellation_worker.py +0 -0
  75. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_cooperative_remote_worker.py +0 -0
  76. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_docstring_cross_references.py +0 -0
  77. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_durable_selection.py +0 -0
  78. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_errors.py +0 -0
  79. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_external_storage.py +0 -0
  80. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_external_task_input.py +0 -0
  81. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_external_task_result.py +0 -0
  82. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_golden_history_replay.py +0 -0
  83. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_history_bundle_verify.py +0 -0
  84. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_history_event_contract.py +0 -0
  85. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_invocable.py +0 -0
  86. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_message_streams.py +0 -0
  87. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_metrics.py +0 -0
  88. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_nexus_namespace.py +0 -0
  89. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_nexus_wire.py +0 -0
  90. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_order_processing_example.py +0 -0
  91. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_parallel_completion_order_replay.py +0 -0
  92. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_parallel_saga_authoring.py +0 -0
  93. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_patch_history.py +0 -0
  94. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_pending_operation_replay.py +0 -0
  95. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_client.py +0 -0
  96. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_groups.py +0 -0
  97. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_policies.py +0 -0
  98. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_prepared_local_activity_worker.py +0 -0
  99. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_public_boundary_scanner.py +0 -0
  100. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_pypi_project_surface.py +0 -0
  101. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_python_conformance.py +0 -0
  102. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_queries.py +0 -0
  103. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_readme_quickstart.py +0 -0
  104. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_redrive_replay.py +0 -0
  105. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_registration_ambiguity.py +0 -0
  106. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_release_metadata.py +0 -0
  107. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay.py +0 -0
  108. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay_conformance.py +0 -0
  109. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_replay_verify.py +0 -0
  110. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_retry_policy.py +0 -0
  111. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_run_cancellation_scope_replay.py +0 -0
  112. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_runtime_external_payload_transport.py +0 -0
  113. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_schedules.py +0 -0
  114. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_scoped_run_cancellation_context.py +0 -0
  115. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_serializer.py +0 -0
  116. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_signals.py +0 -0
  117. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_sleep.py +0 -0
  118. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_standalone_activity_client.py +0 -0
  119. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_sticky_execution.py +0 -0
  120. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_storage_admission.py +0 -0
  121. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_sync.py +0 -0
  122. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_testing_harness.py +0 -0
  123. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_update_signal_condition_replay.py +0 -0
  124. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_updates.py +0 -0
  125. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_wait_condition.py +0 -0
  126. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_worker.py +0 -0
  127. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_worker_session.py +0 -0
  128. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_workflow_entrypoint_validation.py +0 -0
  129. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_workflow_result_timeout.py +0 -0
  130. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/tests/test_workflow_streams.py +0 -0
  131. {durable_workflow-2.5.11 → durable_workflow-2.5.13}/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.11
3
+ Version: 2.5.13
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.5.11"
7
+ version = "2.5.13"
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.11"
75
- registry-version = "2.5.11"
74
+ product-train = "2.5.13"
75
+ registry-version = "2.5.13"
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] = {}
@@ -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
 
@@ -1937,10 +1937,14 @@ class WorkflowContext:
1937
1937
  workflow_command_id: str | None = None,
1938
1938
  cancel_requested: bool = False,
1939
1939
  allow_cancellation_scope_authoring: bool = False,
1940
+ _allow_wall_clock: bool = True,
1940
1941
  ) -> None:
1941
1942
  self._workflow_id = workflow_id
1942
1943
  self._run_id = run_id
1943
- self._current_time = current_time or datetime.now(timezone.utc)
1944
+ self._current_time = current_time
1945
+ if self._current_time is None and _allow_wall_clock:
1946
+ self._current_time = datetime.now(timezone.utc)
1947
+ self._replay_clock_error: str | None = None
1944
1948
  self._workflow_command_id = workflow_command_id or run_id or workflow_id
1945
1949
  self._cancel_requested = bool(cancel_requested)
1946
1950
  self._cancellation_request_id: str | None = None
@@ -2538,6 +2542,12 @@ class WorkflowContext:
2538
2542
  return ContinueAsNew(workflow_type=workflow_type, arguments=list(args), task_queue=task_queue)
2539
2543
 
2540
2544
  def now(self) -> datetime:
2545
+ """Return the persisted workflow-start time, fixed during replay."""
2546
+ if self._current_time is None:
2547
+ raise NonDeterministicReplayError(
2548
+ 0, "persisted workflow-start clock", [],
2549
+ detail=self._replay_clock_error or "Workflow replay clock unavailable: missing persisted start time",
2550
+ )
2541
2551
  return self._current_time
2542
2552
 
2543
2553
  def random(self) -> random.Random:
@@ -2548,7 +2558,7 @@ class WorkflowContext:
2548
2558
  return uuid.UUID(bytes=rand_bytes, version=4)
2549
2559
 
2550
2560
  def uuid7(self) -> uuid.UUID:
2551
- timestamp_ms = int(self._current_time.timestamp() * 1000)
2561
+ timestamp_ms = int(self.now().timestamp() * 1000)
2552
2562
  if timestamp_ms < 0 or timestamp_ms >= (1 << 48):
2553
2563
  raise ValueError("ctx.uuid7() requires ctx.now() within the UUIDv7 timestamp range")
2554
2564
 
@@ -4276,13 +4286,22 @@ def _replay_state(
4276
4286
  resolved_sequences.add(sequence)
4277
4287
 
4278
4288
  workflow_start_time: datetime | None = None
4289
+ replay_clock_error = "Workflow replay clock unavailable: missing WorkflowStarted persisted start time"
4279
4290
  for ev in events:
4280
4291
  etype = _history_event_type(ev)
4281
4292
  if etype == "WorkflowStarted":
4282
- ts = (ev.get("payload") or {}).get("timestamp")
4283
- if ts:
4284
- with contextlib.suppress(ValueError, TypeError):
4285
- workflow_start_time = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
4293
+ start_payload = ev.get("payload")
4294
+ if not isinstance(start_payload, Mapping):
4295
+ start_payload = {}
4296
+ explicit_time = "timestamp" in start_payload
4297
+ ts = start_payload.get("timestamp") if explicit_time else ev.get("recorded_at")
4298
+ clock_source = "payload.timestamp" if explicit_time else "recorded_at"
4299
+ replay_clock_error = f"Workflow replay clock unavailable: invalid or missing WorkflowStarted.{clock_source}"
4300
+ if isinstance(ts, str):
4301
+ with contextlib.suppress(ValueError):
4302
+ parsed_time = datetime.fromisoformat(ts.replace("Z", "+00:00"))
4303
+ if parsed_time.tzinfo is not None and parsed_time.utcoffset() is not None:
4304
+ workflow_start_time = parsed_time
4286
4305
  break
4287
4306
 
4288
4307
  for sequence, membership in scope_history.memberships.items():
@@ -4298,7 +4317,9 @@ def _replay_state(
4298
4317
  workflow_command_id=workflow_command_id,
4299
4318
  cancel_requested=cancel_requested,
4300
4319
  allow_cancellation_scope_authoring=allow_cancellation_scope_authoring,
4320
+ _allow_wall_clock=not events,
4301
4321
  )
4322
+ ctx._replay_clock_error = replay_clock_error if workflow_start_time is None else None
4302
4323
  # Continue-as-new cursor checkpoints describe instance state that predates
4303
4324
  # this run. Seed them before the workflow opens its first stream wait so
4304
4325
  # both the durable wait key and worker acknowledgement use the global
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.11
3
+ Version: 2.5.13
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
@@ -95,11 +95,13 @@ 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
101
102
  tests/test_release_metadata.py
102
103
  tests/test_replay.py
104
+ tests/test_replay_clock.py
103
105
  tests/test_replay_conformance.py
104
106
  tests/test_replay_regression_corpus.py
105
107
  tests/test_replay_verify.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
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from datetime import datetime
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ import pytest
9
+
10
+ from durable_workflow import workflow
11
+ from durable_workflow.errors import NonDeterministicReplayError
12
+ from durable_workflow.workflow import CompleteWorkflow, WorkflowContext, replay
13
+
14
+
15
+ @workflow.defn(name="tests.replay.persisted-clock")
16
+ class PersistedClockWorkflow:
17
+ def run(self, ctx: WorkflowContext) -> str:
18
+ return ctx.now().isoformat(timespec="microseconds")
19
+
20
+
21
+ @workflow.defn(name="tests.replay.persisted-uuid7")
22
+ class PersistedUUID7Workflow:
23
+ def run(self, ctx: WorkflowContext) -> str:
24
+ return str(ctx.uuid7())
25
+
26
+
27
+ @workflow.defn(name="tests.replay.marker-clock")
28
+ class MarkerClockWorkflow:
29
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
30
+ before = ctx.now().isoformat(timespec="microseconds")
31
+ version = yield ctx.get_version("clock-case", 0, 7)
32
+ after = ctx.now().isoformat(timespec="microseconds")
33
+ return [before, version, after, str(ctx.uuid7())]
34
+
35
+
36
+ def result(workflow_cls: type, history: list[dict[str, Any]]) -> Any:
37
+ outcome = replay(workflow_cls, history, [], run_id="original-clock-run", payload_codec="avro")
38
+ assert len(outcome.commands) == 1
39
+ command = outcome.commands[0]
40
+ assert isinstance(command, CompleteWorkflow)
41
+ return command.result
42
+
43
+
44
+ def test_recorded_start_time_survives_repeated_cold_reads_and_later_marker() -> None:
45
+ history = [
46
+ {"event_type": "WorkflowStarted", "recorded_at": "2026-06-01T12:00:00.123456Z", "payload": {}},
47
+ {"event_type": "VersionMarkerRecorded", "recorded_at": "2026-06-01T12:00:03.987654Z",
48
+ "payload": {"sequence": 1, "change_id": "clock-case", "version": 7,
49
+ "min_supported": 0, "max_supported": 7}},
50
+ ]
51
+ original = deepcopy(history)
52
+ first = result(MarkerClockWorkflow, history)
53
+ second = result(MarkerClockWorkflow, history)
54
+ assert first == second
55
+ assert first[:3] == ["2026-06-01T12:00:00.123456+00:00", 7, "2026-06-01T12:00:00.123456+00:00"]
56
+ assert UUID(first[3]).int >> 80 == int(datetime.fromisoformat(first[0]).timestamp() * 1000)
57
+ assert history == original
58
+
59
+
60
+ def test_explicit_payload_time_retains_precedence() -> None:
61
+ history = [{"event_type": "WorkflowStarted", "recorded_at": "2026-06-01T12:00:00.123456Z",
62
+ "payload": {"timestamp": "2026-06-01T14:01:02.654321+02:00"}}]
63
+ assert result(PersistedClockWorkflow, history) == "2026-06-01T14:01:02.654321+02:00"
64
+
65
+
66
+ @pytest.mark.parametrize("invalid", [None, False, 123, "", "not-a-time", "2026-06-01T12:00:00"])
67
+ @pytest.mark.parametrize("explicit", [False, True])
68
+ @pytest.mark.parametrize("workflow_cls", [PersistedClockWorkflow, PersistedUUID7Workflow])
69
+ def test_clock_helpers_refuse_invalid_time_without_fallback(
70
+ invalid: Any, explicit: bool, workflow_cls: type,
71
+ ) -> None:
72
+ event: dict[str, Any] = {"event_type": "WorkflowStarted", "payload": {}}
73
+ if explicit:
74
+ event["recorded_at"] = "2026-06-01T12:00:00.123456Z"
75
+ event["payload"]["timestamp"] = invalid
76
+ else:
77
+ event["recorded_at"] = invalid
78
+ with pytest.raises(NonDeterministicReplayError, match="Workflow replay clock unavailable"):
79
+ result(workflow_cls, [event])
80
+
81
+
82
+ @pytest.mark.parametrize("history", [
83
+ [{"event_type": "WorkflowStarted", "payload": {}}],
84
+ [{"event_type": "SignalReceived", "payload": {"signal_name": "unhandled"}}],
85
+ ])
86
+ def test_missing_replay_time_is_explicit(history: list[dict[str, Any]]) -> None:
87
+ with pytest.raises(NonDeterministicReplayError, match="Workflow replay clock unavailable"):
88
+ result(PersistedClockWorkflow, history)
89
+
90
+
91
+ def test_empty_local_authoring_remains_available() -> None:
92
+ assert datetime.fromisoformat(result(PersistedClockWorkflow, [])).tzinfo is not None
93
+ assert UUID(result(PersistedUUID7Workflow, [])).version == 7
@@ -34,6 +34,7 @@ from tests.test_patch_history import (
34
34
  RepeatedPatchWorkflow,
35
35
  )
36
36
  from tests.test_pending_operation_replay import PendingOperation
37
+ from tests.test_replay_clock import PersistedClockWorkflow
37
38
  from tests.test_update_signal_condition_replay import (
38
39
  PostConditionReceiversWorkflow,
39
40
  UpdateSignalConditionTimerWorkflow,
@@ -278,6 +279,7 @@ class NexusTargetWorkflow:
278
279
 
279
280
 
280
281
  WORKFLOWS = [
282
+ PersistedClockWorkflow,
281
283
  NexusTargetWorkflow,
282
284
  UnsupportedAsyncWorkflow,
283
285
  ChildPolicyAuthorWorkflow,