From 16d8cd13b4ef8e531b26556310c5aa045453f14b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 29 Jul 2026 12:10:37 -0600 Subject: [PATCH 1/2] Fix v1 status payload projection Fetch orchestration payloads in the deprecated v1 status query shims while applying show_input locally. Preserve native durabletask query defaults and cover running, completed, failed, and collection status responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ff9faf2d-e79a-4309-8a67-5d455e4dd7bd --- azure-functions-durable/CHANGELOG.md | 2 + .../azure/durable_functions/client.py | 16 ++-- .../compat/durable_orchestration_status.py | 15 ++-- .../test_client_compat.py | 73 ++++++++++++++++--- 4 files changed, 84 insertions(+), 22 deletions(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index e7538ed6..201411a2 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -32,6 +32,8 @@ avoiding repeated allocation of unused worker resources. FIXED +- Fixed deprecated v1 status-query methods omitting orchestration output, +custom status, and failure details when input display was disabled. - Fixed asynchronous durable-client construction failing after an application event loop had been closed or cleared. - Prevented Durable HTTP calls from forwarding managed identity tokens, diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index aca0b2ec..27221857 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -265,16 +265,19 @@ async def get_status( exist, a falsy status is returned rather than ``None``. The ``show_history`` and ``show_history_output`` flags have no - equivalent in durabletask and are ignored; ``show_input`` maps to - ``fetch_payloads``. + equivalent in durabletask and are ignored. Payloads are fetched to + preserve the v1 output and custom-status fields; ``show_input`` controls + whether the compatibility wrapper exposes the input. """ - state = await self.get_orchestration_state(instance_id, fetch_payloads=show_input) - return DurableOrchestrationStatus.from_orchestration_state(state) + state = await self.get_orchestration_state(instance_id, fetch_payloads=True) + return DurableOrchestrationStatus.from_orchestration_state( + state, include_input=show_input) @deprecated("get_status_all is deprecated; use get_all_orchestration_states instead.") async def get_status_all(self) -> list[DurableOrchestrationStatus]: """Deprecated alias for :meth:`get_all_orchestration_states`.""" - states = await self.get_all_orchestration_states() + states = await self.get_all_orchestration_states( + OrchestrationQuery(fetch_inputs_and_outputs=True)) return [DurableOrchestrationStatus.from_orchestration_state(state) for state in states] @deprecated("raise_event is deprecated; use raise_orchestration_event instead.") @@ -376,7 +379,8 @@ async def get_status_by( query = OrchestrationQuery( created_time_from=created_time_from, created_time_to=created_time_to, - runtime_status=to_durabletask_statuses(runtime_status)) + runtime_status=to_durabletask_statuses(runtime_status), + fetch_inputs_and_outputs=True) states = await self.get_all_orchestration_states(query) return [DurableOrchestrationStatus.from_orchestration_state(state) for state in states] diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py index a692d922..b7fc9a40 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py @@ -29,12 +29,18 @@ class DurableOrchestrationStatus: def __init__(self, state: Optional[OrchestrationState] = None): self._state = state + self._include_input = True @classmethod def from_orchestration_state( - cls, state: Optional[OrchestrationState]) -> "DurableOrchestrationStatus": + cls, + state: Optional[OrchestrationState], + *, + include_input: bool = True) -> "DurableOrchestrationStatus": """Wrap a durabletask ``OrchestrationState`` (or ``None``).""" - return cls(state) + status = cls(state) + status._include_input = include_input + return status @classmethod def from_json(cls, json_obj: Any) -> "DurableOrchestrationStatus": @@ -104,7 +110,7 @@ def last_updated_time(self) -> Optional[datetime]: @property def input_(self) -> Any: """Get the (deserialized) input of the orchestration instance.""" - if self._state is None: + if self._state is None or not self._include_input: return None return self._raw_payload(self._state.serialized_input) @@ -170,8 +176,7 @@ def to_json(self) -> dict[str, Any]: failure_message = getattr(failure, "message", None) if failure is not None else None if failure_message: result["output"] = failure_message - input_ = self._raw_payload( - self._state.serialized_input if self._state is not None else None) + input_ = self.input_ if input_ is not None: result["input"] = input_ if self.runtime_status is not None: diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index 57105dca..a9d780c6 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -179,13 +179,13 @@ async def test_start_new_delegates_to_schedule_new_orchestration(): await client.close() -async def test_get_status_delegates_to_get_orchestration_state(): +async def test_get_status_always_fetches_payloads(): client = _make_client() try: with patch.object(client, "get_orchestration_state", new=AsyncMock(return_value=None)) as mock: with pytest.warns(DeprecationWarning): - await client.get_status("abc", show_input=True) + await client.get_status("abc") mock.assert_awaited_once_with("abc", fetch_payloads=True) finally: await client.close() @@ -198,7 +198,8 @@ async def test_get_status_all_delegates(): new=AsyncMock(return_value=[])) as mock: with pytest.warns(DeprecationWarning): await client.get_status_all() - mock.assert_awaited_once_with() + query = mock.await_args.args[0] + assert query.fetch_inputs_and_outputs is True finally: await client.close() @@ -317,6 +318,7 @@ async def test_get_status_by_maps_statuses(): runtime_status=[df.OrchestrationRuntimeStatus.Running]) query = mock.await_args.args[0] assert query.runtime_status == [OrchestrationStatus.RUNNING] + assert query.fetch_inputs_and_outputs is True finally: await client.close() @@ -505,16 +507,20 @@ def test_entity_class_raises_not_implemented(): # Return-type shims: DurableOrchestrationStatus # --------------------------------------------------------------------------- -def _fake_state(): +def _fake_state( + runtime_status=OrchestrationStatus.RUNNING, + serialized_output='{"out": 2}', + failure_details=None): return SimpleNamespace( name="orch", instance_id="abc", created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), last_updated_at=datetime(2026, 1, 2, tzinfo=timezone.utc), - runtime_status=OrchestrationStatus.RUNNING, + runtime_status=runtime_status, serialized_input='{"in": 1}', - serialized_output='{"out": 2}', + serialized_output=serialized_output, serialized_custom_status='"cs"', + failure_details=failure_details, get_input=lambda: {"in": 1}, get_output=lambda: {"out": 2}, get_custom_status=lambda: "cs", @@ -527,21 +533,60 @@ def test_from_durabletask_status_reverse_mapping(): OrchestrationStatus.CONTINUED_AS_NEW) == df.OrchestrationRuntimeStatus.ContinuedAsNew -async def test_get_status_returns_wrapped_status(): +async def test_get_status_suppresses_only_input_by_default(): client = _make_client() try: + state = _fake_state(runtime_status=OrchestrationStatus.COMPLETED) with patch.object(client, "get_orchestration_state", - new=AsyncMock(return_value=_fake_state())): + new=AsyncMock(return_value=state)): with pytest.warns(DeprecationWarning): status = await client.get_status("abc") assert bool(status) is True assert status.name == "orch" assert status.instance_id == "abc" - assert status.runtime_status == df.OrchestrationRuntimeStatus.Running - assert status.input_ == {"in": 1} + assert status.runtime_status == df.OrchestrationRuntimeStatus.Completed + assert status.input_ is None assert status.output == {"out": 2} assert status.custom_status == "cs" - assert status.to_json()["runtimeStatus"] == "Running" + status_json = status.to_json() + assert status_json["runtimeStatus"] == "Completed" + assert "input" not in status_json + assert status_json["output"] == {"out": 2} + assert status_json["customStatus"] == "cs" + finally: + await client.close() + + +async def test_get_status_show_input_includes_running_input(): + client = _make_client() + try: + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=_fake_state())): + with pytest.warns(DeprecationWarning): + status = await client.get_status("abc", show_input=True) + assert status.runtime_status == df.OrchestrationRuntimeStatus.Running + assert status.input_ == {"in": 1} + assert status.to_json()["input"] == {"in": 1} + finally: + await client.close() + + +async def test_get_status_failed_preserves_failure_output(): + client = _make_client() + try: + state = _fake_state( + runtime_status=OrchestrationStatus.FAILED, + serialized_output=None, + failure_details=SimpleNamespace(message="boom")) + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=state)): + with pytest.warns(DeprecationWarning): + status = await client.get_status("abc") + status_json = status.to_json() + assert status_json["runtimeStatus"] == "Failed" + assert status_json["output"] == "boom" + assert status_json["customStatus"] == "cs" + assert "input" not in status_json finally: await client.close() @@ -569,6 +614,9 @@ async def test_get_status_all_returns_wrapped_list(): statuses = await client.get_status_all() assert len(statuses) == 1 assert statuses[0].runtime_status == df.OrchestrationRuntimeStatus.Running + assert statuses[0].input_ == {"in": 1} + assert statuses[0].output == {"out": 2} + assert statuses[0].custom_status == "cs" finally: await client.close() @@ -582,6 +630,9 @@ async def test_get_status_by_returns_wrapped_list(): statuses = await client.get_status_by( runtime_status=[df.OrchestrationRuntimeStatus.Running]) assert statuses[0].instance_id == "abc" + assert statuses[0].input_ == {"in": 1} + assert statuses[0].output == {"out": 2} + assert statuses[0].custom_status == "cs" finally: await client.close() From a01993f2dc1e97e6e231883065fe07063ba5efbc Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 29 Jul 2026 13:35:43 -0600 Subject: [PATCH 2/2] Address v1 status review feedback Expose failed orchestration messages through the compatibility output property, reuse that projection in JSON output, clarify the shim documentation, and strengthen delegation assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ff9faf2d-e79a-4309-8a67-5d455e4dd7bd --- .../azure/durable_functions/client.py | 5 +++-- .../compat/durable_orchestration_status.py | 20 +++++++------------ .../test_client_compat.py | 3 +++ 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 27221857..029699b7 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -266,8 +266,9 @@ async def get_status( The ``show_history`` and ``show_history_output`` flags have no equivalent in durabletask and are ignored. Payloads are fetched to - preserve the v1 output and custom-status fields; ``show_input`` controls - whether the compatibility wrapper exposes the input. + preserve the v1 output, custom-status, and failure-detail fields; + ``show_input`` controls whether the compatibility wrapper exposes the + input. """ state = await self.get_orchestration_state(instance_id, fetch_payloads=True) return DurableOrchestrationStatus.from_orchestration_state( diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py index b7fc9a40..cc6ea3e0 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py @@ -116,10 +116,14 @@ def input_(self) -> Any: @property def output(self) -> Any: - """Get the (deserialized) output of the orchestration instance.""" + """Get the output or failure message of the orchestration instance.""" if self._state is None: return None - return self._raw_payload(self._state.serialized_output) + output = self._raw_payload(self._state.serialized_output) + if output is not None: + return output + failure = self._state.failure_details + return failure.message if failure is not None else None @property def runtime_status(self) -> Optional[OrchestrationRuntimeStatus]: @@ -163,19 +167,9 @@ def to_json(self) -> dict[str, Any]: result["createdTime"] = self._format_datetime(self.created_time) if self.last_updated_time is not None: result["lastUpdatedTime"] = self._format_datetime(self.last_updated_time) - output = self._raw_payload( - self._state.serialized_output if self._state is not None else None) + output = self.output if output is not None: result["output"] = output - elif self._state is not None: - # A failed orchestration carries its error in ``failure_details`` - # rather than ``serialized_output``; surface it under ``output`` - # (matching v1, where the failure message was returned through the - # status output) so the error is not dropped from the payload. - failure = getattr(self._state, "failure_details", None) - failure_message = getattr(failure, "message", None) if failure is not None else None - if failure_message: - result["output"] = failure_message input_ = self.input_ if input_ is not None: result["input"] = input_ diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index a9d780c6..e2e90bac 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -198,6 +198,7 @@ async def test_get_status_all_delegates(): new=AsyncMock(return_value=[])) as mock: with pytest.warns(DeprecationWarning): await client.get_status_all() + mock.assert_awaited_once() query = mock.await_args.args[0] assert query.fetch_inputs_and_outputs is True finally: @@ -316,6 +317,7 @@ async def test_get_status_by_maps_statuses(): with pytest.warns(DeprecationWarning): await client.get_status_by( runtime_status=[df.OrchestrationRuntimeStatus.Running]) + mock.assert_awaited_once() query = mock.await_args.args[0] assert query.runtime_status == [OrchestrationStatus.RUNNING] assert query.fetch_inputs_and_outputs is True @@ -582,6 +584,7 @@ async def test_get_status_failed_preserves_failure_output(): new=AsyncMock(return_value=state)): with pytest.warns(DeprecationWarning): status = await client.get_status("abc") + assert status.output == "boom" status_json = status.to_json() assert status_json["runtimeStatus"] == "Failed" assert status_json["output"] == "boom"