durable-workflow 2.4.1__tar.gz → 2.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.4.1/src/durable_workflow.egg-info → durable_workflow-2.4.3}/PKG-INFO +1 -1
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/pyproject.toml +3 -3
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/__init__.py +2 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/client.py +16 -2
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/errors.py +30 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_client.py +48 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_errors.py +53 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_pending_operation_replay.py +40 -1
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_sync.py +29 -1
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/LICENSE +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/README.md +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/setup.cfg +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/worker.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_invocable.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_metrics.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_queries.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay_regression_corpus.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_schedules.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_serializer.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_signals.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_sleep.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_updates.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_worker.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.4.
|
|
7
|
+
version = "2.4.3"
|
|
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.4.
|
|
75
|
-
registry-version = "2.4.
|
|
74
|
+
product-train = "2.4.3"
|
|
75
|
+
registry-version = "2.4.3"
|
|
76
76
|
supported-server-versions = "2.5.0"
|
|
77
77
|
worker-protocol-version = "1.19"
|
|
78
78
|
control-plane-version = "2"
|
|
@@ -95,6 +95,7 @@ from .errors import (
|
|
|
95
95
|
ServerError,
|
|
96
96
|
SignalFailed,
|
|
97
97
|
Unauthorized,
|
|
98
|
+
UpdateFailed,
|
|
98
99
|
UpdateRejected,
|
|
99
100
|
UpdateValidationFailed,
|
|
100
101
|
WorkflowAlreadyStarted,
|
|
@@ -385,6 +386,7 @@ __all__ = [
|
|
|
385
386
|
"SignalFailed",
|
|
386
387
|
"TransportRetryPolicy",
|
|
387
388
|
"Unauthorized",
|
|
389
|
+
"UpdateFailed",
|
|
388
390
|
"UpdateRejected",
|
|
389
391
|
"UpdateValidationFailed",
|
|
390
392
|
"WorkflowAlreadyStarted",
|
|
@@ -4349,7 +4349,9 @@ class Client:
|
|
|
4349
4349
|
:class:`~durable_workflow.errors.UpdateRejected` when the workflow's
|
|
4350
4350
|
validator rejects the update, or
|
|
4351
4351
|
:class:`~durable_workflow.errors.UpdateValidationFailed` when the
|
|
4352
|
-
declared validation boundary cannot be enforced.
|
|
4352
|
+
declared validation boundary cannot be enforced. An accepted update
|
|
4353
|
+
whose handler fails raises :class:`~durable_workflow.errors.UpdateFailed`
|
|
4354
|
+
with the failure message, Server response and durable identities.
|
|
4353
4355
|
"""
|
|
4354
4356
|
await self._require_update_wait_stage(wait_for or "accepted")
|
|
4355
4357
|
body: dict[str, Any] = {}
|
|
@@ -5115,11 +5117,23 @@ class Client:
|
|
|
5115
5117
|
|
|
5116
5118
|
Worker-plane endpoint, called by :class:`~durable_workflow.Worker`.
|
|
5117
5119
|
``commands`` is the list of serialized commands the workflow yielded
|
|
5118
|
-
for this task.
|
|
5120
|
+
for this task. An empty list acknowledges waiting for already scheduled
|
|
5121
|
+
history without reporting a workflow failure or scheduling another operation.
|
|
5119
5122
|
"""
|
|
5120
5123
|
if (message_stream_cursors or message_stream_waits) and not _worker_protocol_supports_message_streams():
|
|
5121
5124
|
raise ValueError("message stream completion metadata requires worker protocol 1.15 or newer")
|
|
5122
5125
|
|
|
5126
|
+
if not commands:
|
|
5127
|
+
if message_stream_cursors or message_stream_waits:
|
|
5128
|
+
raise ValueError("Message stream completion metadata requires nonempty commands.")
|
|
5129
|
+
return await self.fail_workflow_task(
|
|
5130
|
+
task_id=task_id,
|
|
5131
|
+
lease_owner=lease_owner,
|
|
5132
|
+
workflow_task_attempt=workflow_task_attempt,
|
|
5133
|
+
message="Workflow task waiting for scheduled history.",
|
|
5134
|
+
failure_type="WorkflowTaskWaitingForHistory",
|
|
5135
|
+
)
|
|
5136
|
+
|
|
5123
5137
|
body: dict[str, Any] = {
|
|
5124
5138
|
"lease_owner": lease_owner,
|
|
5125
5139
|
"workflow_task_attempt": workflow_task_attempt,
|
|
@@ -453,6 +453,29 @@ class NonDeterministicReplayError(DurableWorkflowError):
|
|
|
453
453
|
self.recorded_event_types = list(recorded_event_types)
|
|
454
454
|
|
|
455
455
|
|
|
456
|
+
class UpdateFailed(InvalidArgument):
|
|
457
|
+
"""An accepted workflow update failed during handler execution.
|
|
458
|
+
|
|
459
|
+
``body`` preserves the Server response, including its durable failure
|
|
460
|
+
identity. This inherits from :class:`InvalidArgument` to preserve catches
|
|
461
|
+
written for earlier SDK versions that mapped all HTTP 422 responses there.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
def __init__(self, message: str, *, status: int, body: dict[str, Any]) -> None:
|
|
465
|
+
super().__init__(message)
|
|
466
|
+
self.status = status
|
|
467
|
+
self.body = body
|
|
468
|
+
self.workflow_id = self._identity(body, "workflow_id")
|
|
469
|
+
self.run_id = self._identity(body, "run_id")
|
|
470
|
+
self.update_id = self._identity(body, "update_id")
|
|
471
|
+
self.failure_id = self._identity(body, "failure_id")
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _identity(body: dict[str, Any], name: str) -> str | None:
|
|
475
|
+
value = body.get(name)
|
|
476
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
477
|
+
|
|
478
|
+
|
|
456
479
|
class UpdateRejected(DurableWorkflowError):
|
|
457
480
|
"""A workflow update was rejected by the workflow's validator."""
|
|
458
481
|
|
|
@@ -859,6 +882,13 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
|
|
|
859
882
|
raise signal_failed("signal argument validation failed")
|
|
860
883
|
if reason == "invalid_query_arguments":
|
|
861
884
|
raise query_failed("query argument validation failed")
|
|
885
|
+
if isinstance(body, dict) and body.get("update_status") == "failed":
|
|
886
|
+
failure_message = body.get("failure_message")
|
|
887
|
+
if not isinstance(failure_message, str) or not failure_message.strip():
|
|
888
|
+
failure_message = message
|
|
889
|
+
if not isinstance(failure_message, str) or not failure_message.strip():
|
|
890
|
+
failure_message = "workflow update failed"
|
|
891
|
+
raise UpdateFailed(failure_message, status=status, body=body)
|
|
862
892
|
errors = None
|
|
863
893
|
if isinstance(body, dict):
|
|
864
894
|
errors = body.get("errors") or body.get("validation_errors")
|
|
@@ -30,6 +30,7 @@ from durable_workflow.errors import (
|
|
|
30
30
|
ServerError,
|
|
31
31
|
SignalFailed,
|
|
32
32
|
Unauthorized,
|
|
33
|
+
UpdateFailed,
|
|
33
34
|
UpdateRejected,
|
|
34
35
|
WorkflowAlreadyStarted,
|
|
35
36
|
WorkflowNotFound,
|
|
@@ -2903,6 +2904,25 @@ class TestFailWorkflowTask:
|
|
|
2903
2904
|
assert mock.call_args.args[:2] == (fixture["request"]["method"], f"/api{fixture['request']['path']}")
|
|
2904
2905
|
assert mock.call_args.kwargs["json"] == fixture["request"]["body"]
|
|
2905
2906
|
|
|
2907
|
+
@pytest.mark.asyncio
|
|
2908
|
+
@pytest.mark.parametrize("metadata", [
|
|
2909
|
+
{"message_stream_cursors": [{"stream_name": "input", "sequence": 7}]},
|
|
2910
|
+
{"message_stream_waits": [{"stream_name": "input"}]},
|
|
2911
|
+
])
|
|
2912
|
+
async def test_empty_completion_does_not_discard_message_stream_metadata(
|
|
2913
|
+
self, client: Client, metadata: dict
|
|
2914
|
+
) -> None:
|
|
2915
|
+
with (
|
|
2916
|
+
patch.object(client._http, "request", new_callable=AsyncMock,
|
|
2917
|
+
return_value=_mock_response(200, {"completed": True})) as mock,
|
|
2918
|
+
pytest.raises(ValueError, match="nonempty commands"),
|
|
2919
|
+
):
|
|
2920
|
+
await client.complete_workflow_task(
|
|
2921
|
+
task_id="task", lease_owner="owner", workflow_task_attempt=3,
|
|
2922
|
+
commands=[], **metadata,
|
|
2923
|
+
)
|
|
2924
|
+
mock.assert_not_awaited()
|
|
2925
|
+
|
|
2906
2926
|
@pytest.mark.asyncio
|
|
2907
2927
|
async def test_fail_workflow_task_matches_polyglot_fixture(self, client: Client) -> None:
|
|
2908
2928
|
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "workflow-task-fail-parity.json"
|
|
@@ -3071,6 +3091,34 @@ class TestHeartbeatActivityTask:
|
|
|
3071
3091
|
|
|
3072
3092
|
|
|
3073
3093
|
class TestUpdateWorkflow:
|
|
3094
|
+
@pytest.mark.asyncio
|
|
3095
|
+
async def test_failed_update_retries_preserve_failure_identity(self, client: Client) -> None:
|
|
3096
|
+
body = {
|
|
3097
|
+
"update_status": "failed", "accepted": True,
|
|
3098
|
+
"failure_message": "inventory unavailable", "workflow_id": "wf-1",
|
|
3099
|
+
"run_id": "run-1", "update_id": "update-1", "failure_id": "failure-1",
|
|
3100
|
+
}
|
|
3101
|
+
with patch.object(
|
|
3102
|
+
client._http, "request", new_callable=AsyncMock, return_value=_mock_response(422, body),
|
|
3103
|
+
) as request:
|
|
3104
|
+
failures = []
|
|
3105
|
+
for _ in range(2):
|
|
3106
|
+
with pytest.raises(UpdateFailed) as exc_info:
|
|
3107
|
+
await client.update_workflow(
|
|
3108
|
+
"wf-1", "reserve", wait_for="completed", request_id="request-1",
|
|
3109
|
+
)
|
|
3110
|
+
failures.append(exc_info.value)
|
|
3111
|
+
assert request.await_count == 2
|
|
3112
|
+
assert all(call.kwargs["json"]["request_id"] == "request-1"
|
|
3113
|
+
for call in request.await_args_list)
|
|
3114
|
+
for error in failures:
|
|
3115
|
+
assert str(error) == "inventory unavailable"
|
|
3116
|
+
assert error.status == 422
|
|
3117
|
+
assert error.body == body
|
|
3118
|
+
assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
|
|
3119
|
+
"wf-1", "run-1", "update-1", "failure-1",
|
|
3120
|
+
)
|
|
3121
|
+
|
|
3074
3122
|
@pytest.mark.asyncio
|
|
3075
3123
|
async def test_unsupported_wait_stage_raises_typed_error_before_update(self) -> None:
|
|
3076
3124
|
client = Client("http://localhost:8080", token="test-token")
|
|
@@ -13,6 +13,7 @@ from durable_workflow.errors import (
|
|
|
13
13
|
ServerError,
|
|
14
14
|
SignalFailed,
|
|
15
15
|
Unauthorized,
|
|
16
|
+
UpdateFailed,
|
|
16
17
|
UpdateRejected,
|
|
17
18
|
UpdateValidationFailed,
|
|
18
19
|
WorkflowAlreadyStarted,
|
|
@@ -102,6 +103,56 @@ class TestRaiseForStatus:
|
|
|
102
103
|
with pytest.raises(InvalidArgument) as exc_info:
|
|
103
104
|
_raise_for_status(422, {"message": "bad", "errors": {"f": ["req"]}})
|
|
104
105
|
assert exc_info.value.errors == {"f": ["req"]}
|
|
106
|
+
assert type(exc_info.value) is InvalidArgument
|
|
107
|
+
|
|
108
|
+
def test_422_accepted_update_failure_preserves_diagnostics_and_legacy_catch(self) -> None:
|
|
109
|
+
body = {
|
|
110
|
+
"update_status": "failed", "accepted": True,
|
|
111
|
+
"failure_message": "inventory unavailable", "message": "generic failure",
|
|
112
|
+
"workflow_id": "wf-1", "run_id": "run-1", "update_id": "update-1",
|
|
113
|
+
"failure_id": "failure-1",
|
|
114
|
+
}
|
|
115
|
+
with pytest.raises(InvalidArgument) as exc_info:
|
|
116
|
+
_raise_for_status(422, body)
|
|
117
|
+
error = exc_info.value
|
|
118
|
+
assert isinstance(error, UpdateFailed)
|
|
119
|
+
assert str(error) == "inventory unavailable"
|
|
120
|
+
assert error.status == 422
|
|
121
|
+
assert error.body is body
|
|
122
|
+
assert error.errors is None
|
|
123
|
+
assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
|
|
124
|
+
"wf-1", "run-1", "update-1", "failure-1",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("failure_message", [None, "", " ", 42])
|
|
128
|
+
@pytest.mark.parametrize("message", ["handler failed", None, "", " ", 42])
|
|
129
|
+
def test_update_failure_message_fallback(self, failure_message: object, message: object) -> None:
|
|
130
|
+
with pytest.raises(UpdateFailed) as exc_info:
|
|
131
|
+
_raise_for_status(422, {
|
|
132
|
+
"update_status": "failed", "failure_message": failure_message, "message": message,
|
|
133
|
+
})
|
|
134
|
+
assert str(exc_info.value) == (
|
|
135
|
+
message if message == "handler failed" else "workflow update failed"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def test_update_failure_missing_or_malformed_identities_remain_absent(self) -> None:
|
|
139
|
+
with pytest.raises(UpdateFailed) as exc_info:
|
|
140
|
+
_raise_for_status(422, {
|
|
141
|
+
"update_status": "failed", "workflow_id": 42, "run_id": " ", "update_id": None,
|
|
142
|
+
})
|
|
143
|
+
error = exc_info.value
|
|
144
|
+
assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
|
|
145
|
+
None, None, None, None,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def test_rejected_update_arguments_are_not_handler_failure(self) -> None:
|
|
149
|
+
with pytest.raises(InvalidArgument) as exc_info:
|
|
150
|
+
_raise_for_status(422, {
|
|
151
|
+
"update_status": "rejected", "message": "invalid arguments",
|
|
152
|
+
"validation_errors": {"quantity": ["must be positive"]},
|
|
153
|
+
})
|
|
154
|
+
assert type(exc_info.value) is InvalidArgument
|
|
155
|
+
assert exc_info.value.errors == {"quantity": ["must be positive"]}
|
|
105
156
|
|
|
106
157
|
def test_422_update_validator_rejected_is_typed(self) -> None:
|
|
107
158
|
with pytest.raises(UpdateRejected) as exc_info:
|
|
@@ -109,6 +160,7 @@ class TestRaiseForStatus:
|
|
|
109
160
|
422,
|
|
110
161
|
{
|
|
111
162
|
"reason": "update_validator_rejected",
|
|
163
|
+
"update_status": "failed",
|
|
112
164
|
"message": "approval required",
|
|
113
165
|
"validation_errors": {"approved": ["must be true"]},
|
|
114
166
|
},
|
|
@@ -132,6 +184,7 @@ class TestRaiseForStatus:
|
|
|
132
184
|
status,
|
|
133
185
|
{
|
|
134
186
|
"reason": reason,
|
|
187
|
+
"update_status": "failed",
|
|
135
188
|
"message": "validation could not complete",
|
|
136
189
|
"retryable": retryable,
|
|
137
190
|
},
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
from typing import Any
|
|
5
|
+
from unittest.mock import AsyncMock, patch
|
|
5
6
|
|
|
7
|
+
import httpx
|
|
6
8
|
import pytest
|
|
7
9
|
|
|
8
|
-
from durable_workflow import serializer, workflow
|
|
10
|
+
from durable_workflow import Client, Worker, serializer, workflow
|
|
9
11
|
from durable_workflow.errors import NonDeterministicReplayError
|
|
10
12
|
from durable_workflow.workflow import CompleteWorkflow, WorkflowContext, query_state, replay
|
|
11
13
|
|
|
@@ -78,6 +80,43 @@ def test_signal_replay_does_not_reschedule_pending_operation(kind: str) -> None:
|
|
|
78
80
|
)
|
|
79
81
|
|
|
80
82
|
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
@pytest.mark.parametrize("kind", OPENINGS)
|
|
85
|
+
async def test_worker_acknowledges_pending_signal_replay_with_original_lease(kind: str) -> None:
|
|
86
|
+
history = initial_history() + [OPENINGS[kind], {
|
|
87
|
+
"event_type": "SignalReceived", "payload": {
|
|
88
|
+
"signal_name": "increment", "arguments": serializer.envelope([7]),
|
|
89
|
+
},
|
|
90
|
+
}]
|
|
91
|
+
requests = []
|
|
92
|
+
|
|
93
|
+
async def server(method, path, **kwargs): # type: ignore[no-untyped-def]
|
|
94
|
+
body = kwargs["json"]
|
|
95
|
+
requests.append((method, path, body, kwargs["headers"]["Authorization"]))
|
|
96
|
+
if path.endswith("/complete") and not body["commands"]:
|
|
97
|
+
return httpx.Response(422, json={"message": "The commands field is required."},
|
|
98
|
+
request=httpx.Request(method, "http://test" + path))
|
|
99
|
+
assert path.endswith("/fail")
|
|
100
|
+
return httpx.Response(200, json={"outcome": "waiting_for_history", "recorded": True},
|
|
101
|
+
request=httpx.Request(method, "http://test" + path))
|
|
102
|
+
|
|
103
|
+
async with Client("http://test", worker_token="worker-token") as client:
|
|
104
|
+
worker = Worker(client, task_queue="queue", worker_id="original-owner", workflows=[PendingOperation])
|
|
105
|
+
with patch.object(client._http, "request", new=AsyncMock(side_effect=server)):
|
|
106
|
+
commands = await worker._run_workflow_task({
|
|
107
|
+
"task_id": "original-task", "workflow_task_attempt": 4, "run_id": "original-run",
|
|
108
|
+
"workflow_type": "tests.replay.pending-operation", "history_events": history,
|
|
109
|
+
"payload_codec": "avro", "arguments": serializer.envelope([kind]),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
assert commands == []
|
|
113
|
+
assert requests == [("POST", "/api/worker/workflow-tasks/original-task/fail", {
|
|
114
|
+
"lease_owner": "original-owner", "workflow_task_attempt": 4,
|
|
115
|
+
"failure": {"message": "Workflow task waiting for scheduled history.",
|
|
116
|
+
"type": "WorkflowTaskWaitingForHistory"},
|
|
117
|
+
}, "Bearer worker-token")]
|
|
118
|
+
|
|
119
|
+
|
|
81
120
|
@pytest.mark.parametrize("kind", OPENINGS)
|
|
82
121
|
def test_new_operation_is_still_scheduled(kind: str) -> None:
|
|
83
122
|
outcome = replay(PendingOperation, initial_history(), [kind])
|
|
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch
|
|
|
9
9
|
import httpx
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
|
-
from durable_workflow import serializer
|
|
12
|
+
from durable_workflow import InvalidArgument, UpdateFailed, serializer
|
|
13
13
|
from durable_workflow.client import WorkflowHandle
|
|
14
14
|
from durable_workflow.external_storage import ExternalPayloadCache, LocalFilesystemExternalStorage
|
|
15
15
|
from durable_workflow.sync import Client, SyncStandaloneActivityHandle, SyncWorkflowHandle
|
|
@@ -581,6 +581,34 @@ class TestSyncClientMaintenance:
|
|
|
581
581
|
|
|
582
582
|
|
|
583
583
|
class TestSyncClientUpdate:
|
|
584
|
+
def test_failed_update_preserves_diagnostics(self) -> None:
|
|
585
|
+
body = {
|
|
586
|
+
"update_status": "failed", "failure_message": "inventory unavailable",
|
|
587
|
+
"workflow_id": "wf-1", "run_id": "run-1", "update_id": "update-1",
|
|
588
|
+
"failure_id": "failure-1",
|
|
589
|
+
}
|
|
590
|
+
with Client("http://localhost:8080") as client:
|
|
591
|
+
client._async._cluster_info = {
|
|
592
|
+
"control_plane": {"request_contract": {"operations": {"update": {
|
|
593
|
+
"fields": {"wait_for": {"canonical_values": ["accepted", "completed"]}},
|
|
594
|
+
}}}},
|
|
595
|
+
}
|
|
596
|
+
with (
|
|
597
|
+
patch.object(client._async._http, "request", new_callable=AsyncMock,
|
|
598
|
+
return_value=_mock_response(422, body)) as request,
|
|
599
|
+
pytest.raises(InvalidArgument) as exc_info,
|
|
600
|
+
):
|
|
601
|
+
client.update_workflow("wf-1", "reserve", request_id="request-1")
|
|
602
|
+
error = exc_info.value
|
|
603
|
+
assert isinstance(error, UpdateFailed)
|
|
604
|
+
assert str(error) == "inventory unavailable"
|
|
605
|
+
assert error.status == 422
|
|
606
|
+
assert error.body == body
|
|
607
|
+
assert (error.workflow_id, error.run_id, error.update_id, error.failure_id) == (
|
|
608
|
+
"wf-1", "run-1", "update-1", "failure-1",
|
|
609
|
+
)
|
|
610
|
+
assert request.call_args.kwargs["json"]["request_id"] == "request-1"
|
|
611
|
+
|
|
584
612
|
def test_update(self) -> None:
|
|
585
613
|
client = Client("http://localhost:8080")
|
|
586
614
|
client._async._cluster_info = {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cancellation_scope.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_cooperative_cancellation.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/_prepared_local_activity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_task_input.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/external_task_result.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/python_conformance.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow/replay_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/requires.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/src/durable_workflow.egg-info/top_level.txt
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_activity_cancellation_policies.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_admission.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cancellation_scope_authoring.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_history.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_committed_cancellation_scope_replay.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_control_plane_parity_fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_cooperative_cancellation_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_parallel_completion_order_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_client.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_groups.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_policies.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_prepared_local_activity_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_run_cancellation_scope_replay.py
RENAMED
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_runtime_external_payload_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_scoped_run_cancellation_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_update_signal_condition_replay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durable_workflow-2.4.1 → durable_workflow-2.4.3}/tests/test_workflow_updates_conformance.py
RENAMED
|
File without changes
|