durable-workflow 2.0.3__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {durable_workflow-2.0.3/src/durable_workflow.egg-info → durable_workflow-2.1.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/pyproject.toml +4 -4
  3. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/client.py +85 -3
  4. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/errors.py +2 -0
  5. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/retry_policy.py +6 -0
  6. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/sync.py +16 -0
  7. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/workflow.py +24 -0
  8. {durable_workflow-2.0.3 → durable_workflow-2.1.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  10. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_client.py +38 -0
  11. durable_workflow-2.1.0/tests/test_redrive_replay.py +46 -0
  12. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_release_metadata.py +19 -12
  13. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay.py +51 -0
  14. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_runtime_external_payload_transport.py +110 -0
  15. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_storage_admission.py +6 -2
  16. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_sync.py +22 -0
  17. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_worker.py +27 -0
  18. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/LICENSE +0 -0
  19. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/README.md +0 -0
  20. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/setup.cfg +0 -0
  21. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/__init__.py +0 -0
  22. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/_avro.py +0 -0
  23. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/activity.py +0 -0
  24. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/auth_composition.py +0 -0
  25. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/external_storage.py +0 -0
  26. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/external_task_input.py +0 -0
  27. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/external_task_result.py +0 -0
  28. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  29. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/interceptors.py +0 -0
  30. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/invocable.py +0 -0
  31. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/metrics.py +0 -0
  32. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/nexus.py +0 -0
  33. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/py.typed +0 -0
  34. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/python_conformance.py +0 -0
  35. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/replay_conformance.py +0 -0
  36. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/replay_verify.py +0 -0
  37. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/serializer.py +0 -0
  38. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/testing.py +0 -0
  39. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/worker.py +0 -0
  40. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  41. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  42. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  43. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  44. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  45. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_activity_context.py +0 -0
  46. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_auth_composition.py +0 -0
  47. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_avro_value_benchmark.py +0 -0
  48. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_avro_value_protocol.py +0 -0
  49. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_ci_checkout.py +0 -0
  50. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_ci_integration_endpoint.py +0 -0
  51. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_codec_regression_corpus.py +0 -0
  52. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  53. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_docstring_cross_references.py +0 -0
  54. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_durable_selection.py +0 -0
  55. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_errors.py +0 -0
  56. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_external_storage.py +0 -0
  57. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_external_task_input.py +0 -0
  58. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_external_task_result.py +0 -0
  59. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_golden_history_replay.py +0 -0
  60. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_history_bundle_verify.py +0 -0
  61. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_history_event_contract.py +0 -0
  62. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_invocable.py +0 -0
  63. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_message_streams.py +0 -0
  64. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_metrics.py +0 -0
  65. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_order_processing_example.py +0 -0
  66. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_parallel_completion_order_replay.py +0 -0
  67. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_parallel_saga_authoring.py +0 -0
  68. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_public_boundary_scanner.py +0 -0
  69. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_pypi_project_surface.py +0 -0
  70. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_python_conformance.py +0 -0
  71. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_queries.py +0 -0
  72. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_readme_quickstart.py +0 -0
  73. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay_conformance.py +0 -0
  74. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay_regression_corpus.py +0 -0
  75. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_replay_verify.py +0 -0
  76. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_retry_policy.py +0 -0
  77. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_schedules.py +0 -0
  78. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_serializer.py +0 -0
  79. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_signals.py +0 -0
  80. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_sleep.py +0 -0
  81. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_standalone_activity_client.py +0 -0
  82. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_testing_harness.py +0 -0
  83. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_update_signal_condition_replay.py +0 -0
  84. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_updates.py +0 -0
  85. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_wait_condition.py +0 -0
  86. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_workflow_result_timeout.py +0 -0
  87. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/tests/test_workflow_streams.py +0 -0
  88. {durable_workflow-2.0.3 → durable_workflow-2.1.0}/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.0.3
3
+ Version: 2.1.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.0.3"
7
+ version = "2.1.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"
@@ -71,9 +71,9 @@ 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.0.3"
75
- registry-version = "2.0.3"
76
- supported-server-versions = "2.0.0"
74
+ product-train = "2.1.0"
75
+ registry-version = "2.1.0"
76
+ supported-server-versions = "2.4.0"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
79
79
  durable-selection = true
@@ -20,8 +20,10 @@ from __future__ import annotations
20
20
 
21
21
  import asyncio
22
22
  import hashlib
23
+ import json as json_module
23
24
  import math
24
25
  import os
26
+ import re
25
27
  import time
26
28
  import uuid
27
29
  import warnings
@@ -30,7 +32,7 @@ from dataclasses import dataclass
30
32
  from importlib.metadata import PackageNotFoundError
31
33
  from importlib.metadata import version as _pkg_version
32
34
  from typing import Any
33
- from urllib.parse import quote, urlencode, urlsplit
35
+ from urllib.parse import quote, unquote, urlencode, urlsplit
34
36
 
35
37
  import httpx
36
38
 
@@ -101,6 +103,26 @@ _RUNTIME_EXTERNAL_PAYLOAD_FETCH_PATH_TEMPLATE = (
101
103
  "/external-payloads/v1/{referenceId}"
102
104
  )
103
105
  _RUNTIME_EXTERNAL_PAYLOAD_ERROR_BODY_LIMIT = 64 * 1024
106
+ _PAYLOAD_COMPLETION_SCHEMA = "durable-workflow.v2.payload-completion-context.v1"
107
+ _PAYLOAD_COMPLETION_HEADER = "X-Durable-Workflow-Payload-Completion"
108
+
109
+
110
+ def _payload_completion_context(path: str, body: Any) -> dict[str, Any] | None:
111
+ match = re.fullmatch(r"/worker/(activity|workflow|query)-tasks/([^/]+)/(complete|fail)", path.split("?")[0])
112
+ if match is None or not isinstance(body, dict):
113
+ return None
114
+ kind, task_id, operation = match.groups()
115
+ attempt = body.get("activity_attempt_id" if kind == "activity" else f"{kind}_task_attempt")
116
+ owner = body.get("lease_owner")
117
+ if not isinstance(owner, str) or not owner:
118
+ return None
119
+ if kind == "activity":
120
+ if not isinstance(attempt, str) or not attempt:
121
+ return None
122
+ elif type(attempt) is not int or attempt < 1:
123
+ return None
124
+ return {"schema": _PAYLOAD_COMPLETION_SCHEMA, "kind": kind, "task_id": unquote(task_id),
125
+ "attempt": attempt, "lease_owner": owner, "operation": operation}
104
126
 
105
127
 
106
128
  def _default_sdk_version() -> str:
@@ -277,6 +299,7 @@ class _RuntimeExternalPayloadTransport:
277
299
  max_payload_bytes: int
278
300
  request_timeout_seconds: float
279
301
  status: str
302
+ completion_context: bool = False
280
303
 
281
304
 
282
305
  @dataclass
@@ -577,6 +600,7 @@ class WorkflowCommandResult:
577
600
  command_status: str | None = None
578
601
  command_id: str | None = None
579
602
  raw: dict[str, Any] | None = None
603
+ run_id: str | None = None
580
604
 
581
605
  @classmethod
582
606
  def from_dict(cls, data: dict[str, Any], *, workflow_id: str | None = None) -> WorkflowCommandResult:
@@ -586,6 +610,7 @@ class WorkflowCommandResult:
586
610
  command_status=data.get("command_status"),
587
611
  command_id=data.get("command_id"),
588
612
  raw=data,
613
+ run_id=data.get("run_id"),
589
614
  )
590
615
 
591
616
 
@@ -1302,6 +1327,12 @@ class WorkflowHandle:
1302
1327
  """Ask the server to repair this workflow. See :meth:`Client.repair_workflow`."""
1303
1328
  return await self._client.repair_workflow(self.workflow_id)
1304
1329
 
1330
+ async def redrive(self, *, request_id: str | None = None) -> WorkflowCommandResult:
1331
+ """Continue this failed run from its recorded activity failure boundary."""
1332
+ if self.run_id is None:
1333
+ raise ValueError("run_id is required to redrive a workflow from a handle")
1334
+ return await self._client.redrive_workflow(self.workflow_id, self.run_id, request_id=request_id)
1335
+
1305
1336
  async def archive(self, *, reason: str | None = None) -> WorkflowCommandResult:
1306
1337
  """Move this terminal workflow into the archive tier. See :meth:`Client.archive_workflow`."""
1307
1338
  return await self._client.archive_workflow(self.workflow_id, reason=reason)
@@ -1734,6 +1765,9 @@ class Client:
1734
1765
  worker=worker,
1735
1766
  transport=transport,
1736
1767
  uploaded={},
1768
+ completion=(
1769
+ _payload_completion_context(path, json) if worker and transport.completion_context else None
1770
+ ),
1737
1771
  )
1738
1772
 
1739
1773
  start = time.perf_counter()
@@ -1904,11 +1938,15 @@ class Client:
1904
1938
  )
1905
1939
 
1906
1940
  status = policy.get("status")
1941
+ completion = upload.get("completion_context")
1907
1942
  transport = _RuntimeExternalPayloadTransport(
1908
1943
  threshold_bytes=threshold_bytes,
1909
1944
  max_payload_bytes=max_payload_bytes,
1910
1945
  request_timeout_seconds=float(request_timeout_seconds),
1911
1946
  status=status if isinstance(status, str) else "unknown",
1947
+ completion_context=isinstance(completion, dict)
1948
+ and completion.get("schema") == _PAYLOAD_COMPLETION_SCHEMA
1949
+ and completion.get("header") == _PAYLOAD_COMPLETION_HEADER,
1912
1950
  )
1913
1951
  self._runtime_external_payload_transport_cache = transport
1914
1952
  self._runtime_external_payload_transport_resolved = True
@@ -1972,6 +2010,8 @@ class Client:
1972
2010
  worker: bool,
1973
2011
  transport: _RuntimeExternalPayloadTransport,
1974
2012
  uploaded: dict[tuple[str, str, int], RuntimeExternalPayloadReference],
2013
+ completion: dict[str, Any] | None = None,
2014
+ slot: tuple[str | int, ...] = (),
1975
2015
  ) -> Any:
1976
2016
  if isinstance(value, dict):
1977
2017
  if (
@@ -1984,6 +2024,8 @@ class Client:
1984
2024
  worker=worker,
1985
2025
  transport=transport,
1986
2026
  uploaded=uploaded,
2027
+ completion=completion,
2028
+ slot=(*slot, "result"),
1987
2029
  )
1988
2030
  if "external_payload" in externalized_result:
1989
2031
  normalized_command["result"] = externalized_result
@@ -2014,6 +2056,7 @@ class Client:
2014
2056
  sha256=sha256,
2015
2057
  worker=worker,
2016
2058
  transport=transport,
2059
+ completion={**completion, "slot": list(slot)} if completion is not None else None,
2017
2060
  )
2018
2061
  uploaded[identity] = reference
2019
2062
  return {"codec": codec, "external_payload": reference.to_dict()}
@@ -2024,6 +2067,8 @@ class Client:
2024
2067
  worker=worker,
2025
2068
  transport=transport,
2026
2069
  uploaded=uploaded,
2070
+ completion=completion,
2071
+ slot=(*slot, key),
2027
2072
  )
2028
2073
  for key, item in value.items()
2029
2074
  }
@@ -2045,8 +2090,10 @@ class Client:
2045
2090
  worker=worker,
2046
2091
  transport=transport,
2047
2092
  uploaded=uploaded,
2093
+ completion=completion,
2094
+ slot=(*slot, index),
2048
2095
  )
2049
- for item in value
2096
+ for index, item in enumerate(value)
2050
2097
  ]
2051
2098
  return value
2052
2099
 
@@ -2058,6 +2105,7 @@ class Client:
2058
2105
  sha256: str,
2059
2106
  worker: bool,
2060
2107
  transport: _RuntimeExternalPayloadTransport,
2108
+ completion: dict[str, Any] | None = None,
2061
2109
  ) -> RuntimeExternalPayloadReference:
2062
2110
  headers = self._headers(worker=worker)
2063
2111
  headers.update({
@@ -2069,13 +2117,30 @@ class Client:
2069
2117
  })
2070
2118
 
2071
2119
  async def _do_request() -> httpx.Response:
2120
+ attempt_headers = dict(headers)
2072
2121
  response = await self._http.request(
2073
2122
  "POST",
2074
2123
  f"/api{_RUNTIME_EXTERNAL_PAYLOAD_UPLOAD_PATH}",
2075
- headers=headers,
2124
+ headers=attempt_headers,
2076
2125
  content=data,
2077
2126
  timeout=transport.request_timeout_seconds,
2078
2127
  )
2128
+ if (response.status_code == 503 and completion is not None
2129
+ and len(response.content) <= _RUNTIME_EXTERNAL_PAYLOAD_ERROR_BODY_LIMIT):
2130
+ try:
2131
+ refusal = response.json()
2132
+ except ValueError:
2133
+ refusal = None
2134
+ context = json_module.dumps(completion, separators=(",", ":"))
2135
+ if (isinstance(refusal, dict) and refusal.get("reason") == "storage_pressure"
2136
+ and refusal.get("storage_state") == "draining" and len(context.encode("utf-8")) <= 4096):
2137
+ # Keep the same identity if the ordinary transport policy retries
2138
+ # an ambiguous response; never rerun application activity code.
2139
+ attempt_headers[_PAYLOAD_COMPLETION_HEADER] = context
2140
+ response = await self._http.request(
2141
+ "POST", f"/api{_RUNTIME_EXTERNAL_PAYLOAD_UPLOAD_PATH}", headers=attempt_headers,
2142
+ content=data, timeout=transport.request_timeout_seconds,
2143
+ )
2079
2144
  response.raise_for_status()
2080
2145
  return response
2081
2146
 
@@ -4076,6 +4141,23 @@ class Client:
4076
4141
  data = await self._request("POST", f"/workflows/{workflow_id}/repair", json={}, context=workflow_id)
4077
4142
  return WorkflowCommandResult.from_dict(data, workflow_id=workflow_id)
4078
4143
 
4144
+ async def redrive_workflow(
4145
+ self,
4146
+ workflow_id: str,
4147
+ failed_run_id: str,
4148
+ *,
4149
+ request_id: str | None = None,
4150
+ ) -> WorkflowCommandResult:
4151
+ """Continue a failed run, reusing its completed activity results."""
4152
+ body = {"request_id": request_id} if request_id is not None else {}
4153
+ data = await self._request(
4154
+ "POST",
4155
+ f"/workflows/{workflow_id}/runs/{failed_run_id}/redrive",
4156
+ json=body,
4157
+ context=workflow_id,
4158
+ )
4159
+ return WorkflowCommandResult.from_dict(data, workflow_id=workflow_id)
4160
+
4079
4161
  async def archive_workflow(self, workflow_id: str, *, reason: str | None = None) -> WorkflowCommandResult:
4080
4162
  """Move a terminal workflow into the archive tier, returning the command outcome."""
4081
4163
  body: dict[str, Any] = {}
@@ -551,6 +551,7 @@ class ActivityFailed(DurableWorkflowError):
551
551
  message: str,
552
552
  *,
553
553
  activity_type: str | None = None,
554
+ step_sequence: int | None = None,
554
555
  activity_execution_id: str | None = None,
555
556
  activity_attempt_id: str | None = None,
556
557
  failure_id: str | None = None,
@@ -564,6 +565,7 @@ class ActivityFailed(DurableWorkflowError):
564
565
  ) -> None:
565
566
  super().__init__(message)
566
567
  self.activity_type = activity_type
568
+ self.step_sequence = step_sequence
567
569
  self.activity_execution_id = activity_execution_id
568
570
  self.activity_attempt_id = activity_attempt_id
569
571
  self.failure_id = failure_id
@@ -43,6 +43,12 @@ def _storage_refusal(exc: Exception) -> tuple[ServerError, str | None] | None:
43
43
  body = exc.response.json()
44
44
  except ValueError:
45
45
  return None
46
+ # A payload upload is content-addressed and precedes completion submission.
47
+ # Even a late pressure refusal can retry those same bytes. This local retry
48
+ # classification does not alter the original response exposed to callers.
49
+ if (isinstance(body, dict) and "request_admitted" not in body and exc.request.method == "POST"
50
+ and exc.request.url.path.endswith("/api/external-payloads/v1")):
51
+ body = {**body, "request_admitted": False}
46
52
  error = ServerError(exc.response.status_code, body)
47
53
  if error.reason() not in ("storage_pressure", "storage_admission_unavailable"):
48
54
  return None
@@ -134,6 +134,10 @@ class SyncWorkflowHandle:
134
134
  result: WorkflowCommandResult = self._runner.run(self._handle.repair())
135
135
  return result
136
136
 
137
+ def redrive(self, *, request_id: str | None = None) -> WorkflowCommandResult:
138
+ result: WorkflowCommandResult = self._runner.run(self._handle.redrive(request_id=request_id))
139
+ return result
140
+
137
141
  def archive(self, *, reason: str | None = None) -> WorkflowCommandResult:
138
142
  result: WorkflowCommandResult = self._runner.run(self._handle.archive(reason=reason))
139
143
  return result
@@ -525,6 +529,18 @@ class Client:
525
529
  result: WorkflowCommandResult = self._runner.run(self._async.repair_workflow(workflow_id))
526
530
  return result
527
531
 
532
+ def redrive_workflow(
533
+ self,
534
+ workflow_id: str,
535
+ failed_run_id: str,
536
+ *,
537
+ request_id: str | None = None,
538
+ ) -> WorkflowCommandResult:
539
+ result: WorkflowCommandResult = self._runner.run(
540
+ self._async.redrive_workflow(workflow_id, failed_run_id, request_id=request_id)
541
+ )
542
+ return result
543
+
528
544
  def archive_workflow(self, workflow_id: str, *, reason: str | None = None) -> WorkflowCommandResult:
529
545
  result: WorkflowCommandResult = self._runner.run(self._async.archive_workflow(workflow_id, reason=reason))
530
546
  return result
@@ -621,6 +621,8 @@ class FailWorkflow:
621
621
  exception_class: str | None = None
622
622
  exception: dict[str, Any] | None = None
623
623
  non_retryable: bool = False
624
+ failed_step_sequence: int | None = None
625
+ failed_activity_execution_id: str | None = None
624
626
 
625
627
  def to_server_command(
626
628
  self,
@@ -642,6 +644,9 @@ class FailWorkflow:
642
644
  cmd["exception"] = self.exception
643
645
  if self.non_retryable:
644
646
  cmd["non_retryable"] = True
647
+ if self.failed_step_sequence is not None and self.failed_activity_execution_id is not None:
648
+ cmd["failed_step_sequence"] = self.failed_step_sequence
649
+ cmd["failed_activity_execution_id"] = self.failed_activity_execution_id
645
650
  return cmd
646
651
 
647
652
 
@@ -2795,11 +2800,25 @@ def _fail_workflow_from_exception(exc: BaseException, *, prefix: str | None = No
2795
2800
  if activity_failure.activity_attempt_id is not None:
2796
2801
  exception["activity_attempt_id"] = activity_failure.activity_attempt_id
2797
2802
 
2803
+ failed_step_sequence = None
2804
+ failed_activity_execution_id = None
2805
+ if (
2806
+ isinstance(exc, ActivityFailed)
2807
+ and type(exc.step_sequence) is int
2808
+ and exc.step_sequence > 0
2809
+ and isinstance(exc.activity_execution_id, str)
2810
+ and exc.activity_execution_id
2811
+ ):
2812
+ failed_step_sequence = exc.step_sequence
2813
+ failed_activity_execution_id = exc.activity_execution_id
2814
+
2798
2815
  return FailWorkflow(
2799
2816
  message=message,
2800
2817
  exception_type=exception_type,
2801
2818
  exception_class=exception_class,
2802
2819
  exception=exception,
2820
+ failed_step_sequence=failed_step_sequence,
2821
+ failed_activity_execution_id=failed_activity_execution_id,
2803
2822
  )
2804
2823
 
2805
2824
 
@@ -3345,6 +3364,11 @@ def _activity_failed_from_payload(payload: Mapping[str, Any]) -> ActivityFailed:
3345
3364
  return ActivityFailed(
3346
3365
  message or "activity failed",
3347
3366
  activity_type=_activity_type_from_payload(payload),
3367
+ step_sequence=(
3368
+ payload["sequence"]
3369
+ if type(payload.get("sequence")) is int and payload["sequence"] > 0
3370
+ else None
3371
+ ),
3348
3372
  activity_execution_id=_optional_str(payload.get("activity_execution_id")),
3349
3373
  activity_attempt_id=_optional_str(payload.get("activity_attempt_id")),
3350
3374
  failure_id=_optional_str(payload.get("failure_id")),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.0.3
3
+ Version: 2.1.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
@@ -61,6 +61,7 @@ tests/test_pypi_project_surface.py
61
61
  tests/test_python_conformance.py
62
62
  tests/test_queries.py
63
63
  tests/test_readme_quickstart.py
64
+ tests/test_redrive_replay.py
64
65
  tests/test_release_metadata.py
65
66
  tests/test_replay.py
66
67
  tests/test_replay_conformance.py
@@ -832,14 +832,20 @@ class TestWorkflowHandleControlPlane:
832
832
  async def test_maintenance_delegates_to_client(self, client: Client) -> None:
833
833
  handle = WorkflowHandle(client, workflow_id="wf-1", run_id="r1", workflow_type="greeter")
834
834
  client.repair_workflow = AsyncMock(return_value="repair")
835
+ client.redrive_workflow = AsyncMock(return_value="redrive")
835
836
  client.archive_workflow = AsyncMock(return_value="archive")
836
837
 
837
838
  assert await handle.repair() == "repair"
839
+ assert await handle.redrive(request_id="retry-1") == "redrive"
838
840
  assert await handle.archive(reason="retention") == "archive"
839
841
 
840
842
  client.repair_workflow.assert_awaited_once_with("wf-1")
843
+ client.redrive_workflow.assert_awaited_once_with("wf-1", "r1", request_id="retry-1")
841
844
  client.archive_workflow.assert_awaited_once_with("wf-1", reason="retention")
842
845
 
846
+ with pytest.raises(ValueError, match="run_id is required"):
847
+ await WorkflowHandle(client, workflow_id="wf-1").redrive()
848
+
843
849
 
844
850
  class TestSignalWorkflow:
845
851
  @pytest.mark.asyncio
@@ -1065,6 +1071,38 @@ class TestTerminateWorkflow:
1065
1071
 
1066
1072
 
1067
1073
  class TestWorkflowMaintenanceCommands:
1074
+ @pytest.mark.asyncio
1075
+ async def test_redrive_returns_successor_and_sends_optional_request_id(self, client: Client) -> None:
1076
+ resp = _mock_response(202, {
1077
+ "workflow_id": "wf-1",
1078
+ "run_id": "run-2",
1079
+ "outcome": "redriven",
1080
+ "command_status": "accepted",
1081
+ "resume_step_sequence": 2,
1082
+ })
1083
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
1084
+ result = await client.redrive_workflow("wf-1", "run-1", request_id="retry-1")
1085
+
1086
+ assert mock.call_args.args == ("POST", "/api/workflows/wf-1/runs/run-1/redrive")
1087
+ assert mock.call_args.kwargs["json"] == {"request_id": "retry-1"}
1088
+ assert result.workflow_id == "wf-1"
1089
+ assert result.run_id == "run-2"
1090
+ assert result.outcome == "redriven"
1091
+ assert result.raw is not None and result.raw["resume_step_sequence"] == 2
1092
+
1093
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
1094
+ await client.redrive_workflow("wf-1", "run-1")
1095
+ assert mock.call_args.kwargs["json"] == {}
1096
+
1097
+ @pytest.mark.asyncio
1098
+ async def test_redrive_rejection_is_not_reported_as_success(self, client: Client) -> None:
1099
+ resp = _mock_response(409, {"reason": "run_not_failed", "message": "Run is not failed."})
1100
+ with (
1101
+ patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp),
1102
+ pytest.raises(ServerError),
1103
+ ):
1104
+ await client.redrive_workflow("wf-1", "run-1")
1105
+
1068
1106
  @pytest.mark.asyncio
1069
1107
  async def test_repair_request_matches_polyglot_fixture(self, client: Client) -> None:
1070
1108
  fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "workflow-repair-parity.json"
@@ -0,0 +1,46 @@
1
+ from durable_workflow import serializer, workflow
2
+ from durable_workflow.workflow import CompleteWorkflow, ScheduleActivity, replay
3
+
4
+
5
+ @workflow.defn(name="tests.redrive")
6
+ class RedriveWorkflow:
7
+ def run(self, ctx): # type: ignore[no-untyped-def]
8
+ first = yield ctx.schedule_activity("tests.first", [])
9
+ second = yield ctx.schedule_activity("tests.second", [first])
10
+ return {"first": first, "second": second}
11
+
12
+
13
+ def completed(sequence: int, activity_type: str, result: str) -> dict[str, object]:
14
+ payload: dict[str, object] = {
15
+ "sequence": sequence,
16
+ "activity_type": activity_type,
17
+ "result": serializer.encode(result, codec="avro"),
18
+ "payload_codec": "avro",
19
+ }
20
+ if sequence == 1:
21
+ payload["reused_from_run_id"] = "failed-run"
22
+ payload["reused_activity_execution_id"] = "original-first"
23
+
24
+ return {
25
+ "event_type": "ActivityCompleted",
26
+ "payload": payload,
27
+ }
28
+
29
+
30
+ def test_redriven_history_reuses_completed_prefix_and_retries_failed_step() -> None:
31
+ history = [completed(1, "tests.first", "recorded")]
32
+
33
+ retry = replay(RedriveWorkflow, history, []).commands
34
+ assert len(retry) == 1
35
+ assert isinstance(retry[0], ScheduleActivity)
36
+ assert retry[0].activity_type == "tests.second"
37
+ assert retry[0].arguments == ["recorded"]
38
+
39
+ completed_run = replay(
40
+ RedriveWorkflow,
41
+ history + [completed(2, "tests.second", "retried")],
42
+ [],
43
+ ).commands
44
+ assert len(completed_run) == 1
45
+ assert isinstance(completed_run[0], CompleteWorkflow)
46
+ assert completed_run[0].result == {"first": "recorded", "second": "retried"}
@@ -41,10 +41,10 @@ def test_worker_release_identity_matches_supported_server_and_protocol() -> None
41
41
  project = manifest["project"]
42
42
  release = manifest["tool"]["durable-workflow"]
43
43
 
44
- assert project["version"] == "2.0.3"
44
+ assert project["version"] == "2.1.0"
45
45
  assert release["product-train"] == project["version"]
46
- assert release["registry-version"] == "2.0.3"
47
- assert release["supported-server-versions"] == "2.0.0"
46
+ assert release["registry-version"] == "2.1.0"
47
+ assert release["supported-server-versions"] == "2.4.0"
48
48
  assert release["worker-protocol-version"] == PROTOCOL_VERSION == "1.19"
49
49
  assert release["durable-selection"] is True
50
50
  assert release["durable-selection-minimum-worker-protocol-version"] == "1.19"
@@ -86,24 +86,30 @@ def test_release_metadata_loader_uses_tomli_without_stdlib_tomllib(
86
86
  assert imports == ["tomllib", "tomli"]
87
87
 
88
88
 
89
+ @pytest.mark.parametrize(
90
+ ("version", "server_version"),
91
+ [("2.0.1", "2.0.0"), ("2.1.0", "2.4.0")],
92
+ )
89
93
  def test_release_metadata_loader_accepts_the_authorized_stable_identity(
90
94
  monkeypatch: pytest.MonkeyPatch,
95
+ version: str,
96
+ server_version: str,
91
97
  ) -> None:
92
98
  commit = "b" * 40
93
- pyproject = b"""
99
+ pyproject = f"""
94
100
  [project]
95
101
  name = "durable-workflow"
96
- version = "2.0.1"
102
+ version = "{version}"
97
103
  description = "Python SDK for Durable Workflow 2.0"
98
104
  readme = "README.md"
99
105
  classifiers = ["Programming Language :: Python :: 3"]
100
106
 
101
107
  [tool.durable-workflow]
102
- product-train = "2.0.1"
103
- registry-version = "2.0.1"
104
- supported-server-versions = "2.0.0"
108
+ product-train = "{version}"
109
+ registry-version = "{version}"
110
+ supported-server-versions = "{server_version}"
105
111
  worker-protocol-version = "1.19"
106
- """
112
+ """.encode()
107
113
  readme = b"# Durable Workflow\n\nBuild durable Python workflows.\n"
108
114
  runtime = b'PROTOCOL_VERSION = "1.19"\n'
109
115
 
@@ -120,9 +126,10 @@ worker-protocol-version = "1.19"
120
126
 
121
127
  monkeypatch.setattr(check_release_metadata, "_git", git)
122
128
 
123
- source = check_release_metadata.load_source_metadata("2.0.1")
124
- assert source.version == "2.0.1"
125
- assert source.registry_version == "2.0.1"
129
+ source = check_release_metadata.load_source_metadata(version)
130
+ assert source.version == version
131
+ assert source.registry_version == version
132
+ assert source.server_version == server_version
126
133
 
127
134
 
128
135
  def test_normal_project_page_is_retained_as_rendered_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -60,6 +60,15 @@ class OneActivity:
60
60
  return {"greeting": result}
61
61
 
62
62
 
63
+ @workflow.defn(name="translated-activity-failure")
64
+ class TranslatedActivityFailure:
65
+ def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
66
+ try:
67
+ yield ctx.schedule_activity("greet", [])
68
+ except ActivityFailed as exc:
69
+ raise RuntimeError("translated failure") from exc
70
+
71
+
63
72
  @workflow.defn(name="activity-failed-saga")
64
73
  class ActivityFailedSaga:
65
74
  def run(self, ctx: WorkflowContext, order_id: str): # type: ignore[no-untyped-def]
@@ -442,6 +451,48 @@ class TestPublicReplayer:
442
451
 
443
452
 
444
453
  class TestOneActivity:
454
+ def test_uncaught_recorded_activity_failure_claims_only_its_persisted_boundary(self) -> None:
455
+ history = [{
456
+ "event_type": "ActivityFailed",
457
+ "payload": {
458
+ "sequence": 1,
459
+ "activity_type": "greet",
460
+ "activity_execution_id": "activity-1",
461
+ "message": "failed",
462
+ },
463
+ }]
464
+
465
+ outcome = replay(OneActivity, history, ["Ada"])
466
+ command = outcome.commands[0].to_server_command("workers")
467
+
468
+ assert command["type"] == "fail_workflow"
469
+ assert command["failed_step_sequence"] == 1
470
+ assert command["failed_activity_execution_id"] == "activity-1"
471
+
472
+ translated = replay(TranslatedActivityFailure, history, [])
473
+ translated_command = translated.commands[0].to_server_command("workers")
474
+ assert translated_command["type"] == "fail_workflow"
475
+ assert "failed_step_sequence" not in translated_command
476
+ assert "failed_activity_execution_id" not in translated_command
477
+
478
+ handled_history = [{
479
+ "event_type": "ActivityFailed",
480
+ "payload": {**history[0]["payload"], "activity_type": "charge-card"},
481
+ }]
482
+ handled = replay(ActivityFailedSaga, handled_history, ["order-1"])
483
+ assert isinstance(handled.commands[0], ScheduleActivity)
484
+
485
+ def test_activity_failure_without_complete_recorded_identity_does_not_claim_redrive(self) -> None:
486
+ for payload in (
487
+ {"sequence": 1, "activity_type": "greet", "message": "failed"},
488
+ {"activity_execution_id": "activity-1", "activity_type": "greet", "message": "failed"},
489
+ ):
490
+ outcome = replay(OneActivity, [{"event_type": "ActivityFailed", "payload": payload}], ["Ada"])
491
+ command = outcome.commands[0].to_server_command("workers")
492
+ assert command["type"] == "fail_workflow"
493
+ assert "failed_step_sequence" not in command
494
+ assert "failed_activity_execution_id" not in command
495
+
445
496
  def test_first_replay_schedules(self) -> None:
446
497
  outcome = replay(OneActivity, [], ["world"])
447
498
  assert len(outcome.commands) == 1
@@ -19,6 +19,7 @@ from durable_workflow.errors import (
19
19
  ExternalPayloadUnavailable,
20
20
  ExternalPayloadUnsupported,
21
21
  RuntimeCapabilityUnsupported,
22
+ ServerError,
22
23
  )
23
24
  from durable_workflow.external_storage import (
24
25
  RUNTIME_EXTERNAL_PAYLOAD_REFERENCE_SCHEMA,
@@ -174,6 +175,115 @@ class FakeRuntimePayloadServer:
174
175
  return httpx.Response(200, json=response)
175
176
 
176
177
 
178
+ class CompletionPayloadServer(FakeRuntimePayloadServer):
179
+ def __init__(self, *, supported: bool = True, state: str = "draining", reject_bound: bool = False) -> None:
180
+ super().__init__()
181
+ self.supported = supported
182
+ self.state = state
183
+ self.reject_bound = reject_bound
184
+ self.upload_requests: list[httpx.Request] = []
185
+
186
+ def cluster_info(self) -> dict[str, Any]:
187
+ info = super().cluster_info()
188
+ if self.supported:
189
+ info["namespace"]["external_payload_storage"]["transport"]["upload"]["completion_context"] = {
190
+ "schema": "durable-workflow.v2.payload-completion-context.v1",
191
+ "header": "X-Durable-Workflow-Payload-Completion",
192
+ }
193
+ return info
194
+
195
+ def handler(self, request: httpx.Request) -> httpx.Response:
196
+ if request.method == "POST" and request.url.path == "/api/external-payloads/v1":
197
+ self.upload_requests.append(request)
198
+ if self.state != "normal" and "X-Durable-Workflow-Payload-Completion" not in request.headers:
199
+ return httpx.Response(503, json={"reason": "storage_pressure", "storage_state": self.state,
200
+ "request_admitted": False, "retryable": True, "retry_after_seconds": 1})
201
+ if self.reject_bound:
202
+ return httpx.Response(409, json={"reason": "external_payload_completion_lease_rejected",
203
+ "retryable": False, "message": "Lease rejected."})
204
+ return super().handler(request)
205
+
206
+
207
+ def completion_cases() -> list[tuple[str, str, dict[str, Any], list[str | int]]]:
208
+ envelope = serializer.envelope("x" * 100)
209
+ activity = {"lease_owner": "worker", "activity_attempt_id": "attempt"}
210
+ cases = [
211
+ ("activity", "complete", {**activity, "result": envelope}, ["result"]),
212
+ ("activity", "fail", {**activity, "failure": {"details": envelope}}, ["failure", "details"]),
213
+ ("query", "complete", {"lease_owner": "worker", "query_task_attempt": 2, "result_envelope": envelope},
214
+ ["result_envelope"]),
215
+ ]
216
+ for kind, field in [("complete_workflow", "result"), ("schedule_activity", "arguments"),
217
+ ("upsert_memo", "entries"), ("start_service_operation", "request_payload")]:
218
+ cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
219
+ "commands": [{"type": kind, field: envelope}]}, ["commands", 0, field]))
220
+ cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
221
+ "commands": [{"type": "fail_workflow", "exception": {"details": envelope}}]},
222
+ ["commands", 0, "exception", "details"]))
223
+ cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
224
+ "commands": [{"type": "record_side_effect", "result": serializer.encode("x" * 100)}]},
225
+ ["commands", 0, "result"]))
226
+ cases.append(("workflow", "complete", {"lease_owner": "worker", "workflow_task_attempt": 2,
227
+ "commands": [{"type": "record_side_effect", "workflow_stream": {"items": [
228
+ {"payload": envelope, "payload_codec": "avro"}]}}]},
229
+ ["commands", 0, "workflow_stream", "items", 0, "payload"]))
230
+ return cases
231
+
232
+
233
+ @pytest.mark.parametrize("kind,operation,body,slot", completion_cases())
234
+ async def test_draining_upload_retry_carries_current_completion_identity(
235
+ kind: str, operation: str, body: dict[str, Any], slot: list[str | int],
236
+ ) -> None:
237
+ server = CompletionPayloadServer()
238
+ async with runtime_client(server, retry_policy=TransportRetryPolicy(max_attempts=1)) as client:
239
+ await client._request("POST", f"/worker/{kind}-tasks/task/{operation}", worker=True, json=body)
240
+ first, bound = server.upload_requests
241
+ assert "X-Durable-Workflow-Payload-Completion" not in first.headers
242
+ assert json.loads(bound.headers["X-Durable-Workflow-Payload-Completion"]) == {
243
+ "schema": "durable-workflow.v2.payload-completion-context.v1", "kind": kind,
244
+ "task_id": "task", "attempt": "attempt" if kind == "activity" else 2,
245
+ "lease_owner": "worker", "operation": operation, "slot": slot,
246
+ }
247
+ assert first.content == bound.content
248
+ assert first.headers["authorization"] == bound.headers["authorization"]
249
+ assert first.headers["x-namespace"] == bound.headers["x-namespace"]
250
+ assert len(server.requests) == 1
251
+
252
+
253
+ @pytest.mark.parametrize("supported,worker,state", [(False, True, "draining"), (True, False, "draining"),
254
+ (True, True, "fenced")])
255
+ async def test_completion_upload_does_not_bypass_unsupported_client_or_fenced_admission(
256
+ supported: bool, worker: bool, state: str,
257
+ ) -> None:
258
+ server = CompletionPayloadServer(supported=supported, state=state)
259
+ async with runtime_client(server, retry_policy=TransportRetryPolicy(max_attempts=1)) as client:
260
+ with pytest.raises((ServerError, ExternalPayloadError)):
261
+ await client._request("POST", "/worker/activity-tasks/task/complete" if worker else "/workflows",
262
+ worker=worker, json={"lease_owner": "worker", "activity_attempt_id": "attempt",
263
+ "result" if worker else "input": serializer.envelope("x" * 100)})
264
+ assert len(server.upload_requests) == 1
265
+ assert server.requests == []
266
+
267
+
268
+ async def test_rejected_bound_upload_does_not_loop_or_submit_completion() -> None:
269
+ server = CompletionPayloadServer(reject_bound=True)
270
+ async with runtime_client(server, retry_policy=TransportRetryPolicy(max_attempts=3)) as client:
271
+ with pytest.raises((ServerError, ExternalPayloadError)):
272
+ await client.complete_activity_task(task_id="task", activity_attempt_id="attempt", lease_owner="worker",
273
+ result="x" * 100)
274
+ assert len(server.upload_requests) == 2
275
+ assert server.requests == []
276
+
277
+
278
+ async def test_normal_payload_upload_is_unchanged_when_completion_capability_exists() -> None:
279
+ server = CompletionPayloadServer(state="normal")
280
+ async with runtime_client(server) as client:
281
+ await client.complete_activity_task(task_id="task", activity_attempt_id="attempt", lease_owner="worker",
282
+ result="x" * 100)
283
+ assert len(server.upload_requests) == 1
284
+ assert "X-Durable-Workflow-Payload-Completion" not in server.upload_requests[0].headers
285
+
286
+
177
287
  def runtime_client(
178
288
  server: FakeRuntimePayloadServer,
179
289
  *,
@@ -225,8 +225,9 @@ async def test_shutdown_interrupts_pressure_without_another_mutation(monkeypatch
225
225
 
226
226
 
227
227
  @pytest.mark.parametrize("kind", ["activity", "query", "workflow"])
228
+ @pytest.mark.parametrize("late_upload_refusal", [False, True])
228
229
  async def test_runtime_payload_upload_and_acknowledgement_are_not_repeated(
229
- kind: str, retry_sleeps: list[float],
230
+ kind: str, retry_sleeps: list[float], late_upload_refusal: bool,
230
231
  ) -> None:
231
232
  server = FakeRuntimePayloadServer()
232
233
  uploads: list[bytes] = []
@@ -236,7 +237,10 @@ async def test_runtime_payload_upload_and_acknowledgement_are_not_repeated(
236
237
  if request.method == "POST" and request.url.path == "/api/external-payloads/v1":
237
238
  uploads.append(request.content)
238
239
  if len(uploads) <= 3:
239
- return httpx.Response(503, json=pressure())
240
+ refusal = pressure()
241
+ if late_upload_refusal:
242
+ refusal.pop("request_admitted")
243
+ return httpx.Response(503, json=refusal)
240
244
  if request.url.path.endswith("/complete"):
241
245
  acknowledgements.append(request.content)
242
246
  if len(acknowledgements) <= 4:
@@ -519,6 +519,25 @@ class TestSyncClientRunVisibility:
519
519
 
520
520
 
521
521
  class TestSyncClientMaintenance:
522
+ def test_redrive_workflow(self) -> None:
523
+ client = Client("http://localhost:8080")
524
+ resp = _mock_response(
525
+ 202,
526
+ {
527
+ "workflow_id": "wf-1",
528
+ "run_id": "run-2",
529
+ "outcome": "redriven",
530
+ "command_status": "accepted",
531
+ },
532
+ )
533
+ with patch.object(client._async._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
534
+ result = client.redrive_workflow("wf-1", "run-1", request_id="retry-1")
535
+
536
+ assert result.run_id == "run-2"
537
+ assert result.outcome == "redriven"
538
+ assert mock.call_args.args[:2] == ("POST", "/api/workflows/wf-1/runs/run-1/redrive")
539
+ assert mock.call_args.kwargs["json"] == {"request_id": "retry-1"}
540
+
522
541
  def test_repair_workflow(self) -> None:
523
542
  client = Client("http://localhost:8080")
524
543
  resp = _mock_response(
@@ -786,6 +805,7 @@ class TestSyncWorkflowHandleControlPlane:
786
805
  async_handle.export_history = AsyncMock(return_value={"schema": "durable.workflow.history.v2"}) # type: ignore[method-assign]
787
806
  async_handle.list_runs = AsyncMock(return_value=[]) # type: ignore[method-assign]
788
807
  async_handle.describe_run = AsyncMock(return_value={"run_id": "r1"}) # type: ignore[method-assign]
808
+ async_handle.redrive = AsyncMock(return_value={"run_id": "r2"}) # type: ignore[method-assign]
789
809
  async_handle.repair = AsyncMock(return_value={"outcome": "accepted"}) # type: ignore[method-assign]
790
810
  async_handle.archive = AsyncMock(return_value={"outcome": "completed"}) # type: ignore[method-assign]
791
811
  with Client("http://localhost:8080") as client:
@@ -796,7 +816,9 @@ class TestSyncWorkflowHandleControlPlane:
796
816
  assert handle.list_runs() == []
797
817
  assert handle.describe_run() == {"run_id": "r1"}
798
818
  assert handle.repair() == {"outcome": "accepted"}
819
+ assert handle.redrive(request_id="retry-1") == {"run_id": "r2"}
799
820
  assert handle.archive(reason="retention") == {"outcome": "completed"}
821
+ async_handle.redrive.assert_awaited_once_with(request_id="retry-1")
800
822
  async_handle.archive.assert_awaited_once_with(reason="retention")
801
823
 
802
824
 
@@ -1123,6 +1123,33 @@ class TestWorkflowTaskExecution:
1123
1123
  assert commands[0]["arguments"]["codec"] == "avro"
1124
1124
  assert serializer.decode(commands[0]["arguments"]["blob"], codec="avro") == ["hello"]
1125
1125
 
1126
+ @pytest.mark.asyncio
1127
+ async def test_uncaught_recorded_activity_failure_sends_redrive_boundary(
1128
+ self, mock_client: AsyncMock
1129
+ ) -> None:
1130
+ worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
1131
+ await worker._run_workflow_task({
1132
+ "task_id": "failed-activity-task",
1133
+ "workflow_type": "test-wf",
1134
+ "workflow_task_attempt": 1,
1135
+ "history_events": [{
1136
+ "event_type": "ActivityFailed",
1137
+ "payload": {
1138
+ "sequence": 1,
1139
+ "activity_type": "test-act",
1140
+ "activity_execution_id": "activity-execution-1",
1141
+ "message": "failed",
1142
+ },
1143
+ }],
1144
+ "arguments": serializer.encode(["hello"], codec="avro"),
1145
+ "payload_codec": "avro",
1146
+ })
1147
+
1148
+ commands = mock_client.complete_workflow_task.await_args.kwargs["commands"]
1149
+ assert commands[0]["type"] == "fail_workflow"
1150
+ assert commands[0]["failed_step_sequence"] == 1
1151
+ assert commands[0]["failed_activity_execution_id"] == "activity-execution-1"
1152
+
1126
1153
  @pytest.mark.asyncio
1127
1154
  async def test_workflow_task_ambiguous_completion_error_preserves_commands(
1128
1155
  self, mock_client: AsyncMock