From 758c03da2aab103ecc4f56336c681802c3186073 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 29 Jul 2026 14:21:24 -0600 Subject: [PATCH 1/2] Restore check-status response parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4608fda7-552e-4297-a493-96bbeac57614 --- azure-functions-durable/CHANGELOG.md | 4 + .../azure/durable_functions/client.py | 99 +++++++++--- .../http/http_management_payload.py | 19 ++- .../test_client_compat.py | 146 ++++++++++++++++-- 4 files changed, 230 insertions(+), 38 deletions(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index e9c022e..ca6ca85 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -32,6 +32,10 @@ avoiding repeated allocation of unused worker resources. FIXED +- Check-status responses now include the standard `Retry-After: 10` polling +header. Failed orchestrations return HTTP 200 from the wait helper by default; +callers can request HTTP 500 responses through +`return_internal_server_error_on_failure`. - HTTP management payloads now preserve the host-provided management URL templates and configured HTTP base paths, include `rewindPostUri`, encode instance IDs, and use forwarded request origins when enabled by the host. diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index aef0633..e38d714 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -91,7 +91,8 @@ def _build_http_management_payload( http_base_url: str, required_query_string_parameters: str, use_forwarded_host: bool, - request: func.HttpRequest | None) -> HttpManagementPayload: + request: func.HttpRequest | None, + return_internal_server_error_on_failure: bool = False) -> HttpManagementPayload: encoded_instance_id = quote(instance_id, safe="") configured_base_url = http_base_url or base_url request_origin: str | None = None @@ -113,7 +114,9 @@ def _build_http_management_payload( instance_status_url, required_query_string_parameters, management_urls=management_urls, - request_origin=request_origin) + request_origin=request_origin, + return_internal_server_error_on_failure=( + return_internal_server_error_on_failure)) # Client class used for Durable Functions @@ -247,14 +250,24 @@ def _parse_client_configuration(self, client_as_string: str) -> None: # TODO: convert the string value back to timedelta - annoying regex? self.grpcHttpClientTimeout = client.get("grpcHttpClientTimeout") or timedelta(seconds=30) - def create_check_status_response(self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse: + def create_check_status_response( + self, + request: func.HttpRequest, + instance_id: str, + return_internal_server_error_on_failure: bool = False + ) -> func.HttpResponse: """Creates an HTTP response for checking the status of a Durable Function instance. Args: request (func.HttpRequest): The incoming HTTP request. instance_id (str): The ID of the Durable Function instance. + return_internal_server_error_on_failure (bool): Whether status + queries should return HTTP 500 for failed orchestrations. """ - payload = self._get_client_response_links(request, instance_id) + payload = self._get_client_response_links( + request, + instance_id, + return_internal_server_error_on_failure) return func.HttpResponse( body=str(payload), status_code=202, @@ -264,13 +277,16 @@ def create_check_status_response(self, request: func.HttpRequest, instance_id: s # the required query string (webhook key / task hub / connection) # so a client that follows the header is authorized. 'Location': payload['statusQueryGetUri'], + 'Retry-After': '10', }, ) def create_http_management_payload( self, request: func.HttpRequest | str | None = None, - instance_id: str | None = None) -> HttpManagementPayload: + instance_id: str | None = None, + return_internal_server_error_on_failure: bool = False + ) -> HttpManagementPayload: """Creates an HTTP management payload for a Durable Function instance. Two call styles are supported: @@ -286,6 +302,8 @@ def create_http_management_payload( for backwards compatibility, the instance ID when called with a single positional argument. instance_id (str | None): The ID of the Durable Function instance. + return_internal_server_error_on_failure (bool): Whether the status + query should return HTTP 500 for failed orchestrations. """ # Backwards-compatibility: v1 accepted a single positional ``instance_id``. if instance_id is None and isinstance(request, str): @@ -294,9 +312,17 @@ def create_http_management_payload( if instance_id is None: raise TypeError("instance_id is required") resolved_request = request if isinstance(request, func.HttpRequest) else None - return self._get_client_response_links(resolved_request, instance_id) + return self._get_client_response_links( + resolved_request, + instance_id, + return_internal_server_error_on_failure) - def _get_client_response_links(self, request: func.HttpRequest | None, instance_id: str) -> HttpManagementPayload: + def _get_client_response_links( + self, + request: func.HttpRequest | None, + instance_id: str, + return_internal_server_error_on_failure: bool = False + ) -> HttpManagementPayload: return _build_http_management_payload( instance_id, self.managementUrls, @@ -304,7 +330,8 @@ def _get_client_response_links(self, request: func.HttpRequest | None, instance_ self.httpBaseUrl, self.requiredQueryStringParameters, self.useForwardedHost, - request) + request, + return_internal_server_error_on_failure) # ------------------------------------------------------------------ # Backwards-compatibility shims for the v1 azure-functions-durable @@ -521,7 +548,9 @@ async def wait_for_completion_or_create_check_status_response( request: func.HttpRequest, instance_id: str, timeout_in_milliseconds: int = 10000, - retry_interval_in_milliseconds: int = 1000) -> func.HttpResponse: + retry_interval_in_milliseconds: int = 1000, + return_internal_server_error_on_failure: bool = False + ) -> func.HttpResponse: """Wait for an orchestration to complete, or return a check-status response. If the orchestration completes within the timeout, an HTTP response @@ -530,6 +559,9 @@ async def wait_for_completion_or_create_check_status_response( The ``retry_interval_in_milliseconds`` argument has no durabletask equivalent (durabletask waits server-side) and is ignored. + + When ``return_internal_server_error_on_failure`` is true, failed + orchestrations return HTTP 500 instead of HTTP 200. """ if retry_interval_in_milliseconds > timeout_in_milliseconds: raise Exception( @@ -540,10 +572,16 @@ async def wait_for_completion_or_create_check_status_response( state = await self.wait_for_orchestration_completion( instance_id, timeout=timeout_in_milliseconds / 1000) except TimeoutError: - return self.create_check_status_response(request, instance_id) + return self.create_check_status_response( + request, + instance_id, + return_internal_server_error_on_failure) if state is None: - return self.create_check_status_response(request, instance_id) + return self.create_check_status_response( + request, + instance_id, + return_internal_server_error_on_failure) if state.runtime_status == OrchestrationStatus.COMPLETED: return self._create_http_response(200, state.serialized_output) @@ -552,8 +590,12 @@ async def wait_for_completion_or_create_check_status_response( 200, DurableOrchestrationStatus.from_orchestration_state(state).to_json()) if state.runtime_status == OrchestrationStatus.FAILED: return self._create_http_response( - 500, DurableOrchestrationStatus.from_orchestration_state(state).to_json()) - return self.create_check_status_response(request, instance_id) + 500 if return_internal_server_error_on_failure else 200, + DurableOrchestrationStatus.from_orchestration_state(state).to_json()) + return self.create_check_status_response( + request, + instance_id, + return_internal_server_error_on_failure) @deprecated("rewind is deprecated; use rewind_orchestration instead.") async def rewind( @@ -649,32 +691,48 @@ def _parse_client_configuration(self, client_as_string: str) -> None: "grpcHttpClientTimeout") or timedelta(seconds=30) def create_check_status_response( - self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse: - payload = self._get_client_response_links(request, instance_id) + self, + request: func.HttpRequest, + instance_id: str, + return_internal_server_error_on_failure: bool = False + ) -> func.HttpResponse: + payload = self._get_client_response_links( + request, + instance_id, + return_internal_server_error_on_failure) return func.HttpResponse( body=str(payload), status_code=202, headers={ "content-type": "application/json", "Location": payload["statusQueryGetUri"], + "Retry-After": "10", }, ) def create_http_management_payload( self, request: func.HttpRequest | str | None = None, - instance_id: str | None = None) -> HttpManagementPayload: + instance_id: str | None = None, + return_internal_server_error_on_failure: bool = False + ) -> HttpManagementPayload: if instance_id is None and isinstance(request, str): instance_id = request request = None if instance_id is None: raise TypeError("instance_id is required") resolved_request = request if isinstance(request, func.HttpRequest) else None - return self._get_client_response_links(resolved_request, instance_id) + return self._get_client_response_links( + resolved_request, + instance_id, + return_internal_server_error_on_failure) def _get_client_response_links( - self, request: func.HttpRequest | None, - instance_id: str) -> HttpManagementPayload: + self, + request: func.HttpRequest | None, + instance_id: str, + return_internal_server_error_on_failure: bool = False + ) -> HttpManagementPayload: return _build_http_management_payload( instance_id, self.managementUrls, @@ -682,7 +740,8 @@ def _get_client_response_links( self.httpBaseUrl, self.requiredQueryStringParameters, self.useForwardedHost, - request) + request, + return_internal_server_error_on_failure) def _close_cached_sync_clients() -> None: diff --git a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py index e92567e..4c85a7d 100644 --- a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py +++ b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py @@ -29,7 +29,8 @@ def __init__( required_query_string_parameters: str, *, management_urls: Mapping[str, str] | None = None, - request_origin: str | None = None): + request_origin: str | None = None, + return_internal_server_error_on_failure: bool = False): """Initializes the HttpManagementPayload with the necessary URLs. Args: @@ -40,6 +41,8 @@ def __init__( provided by the Durable extension. request_origin (str | None): Externally visible request origin used to replace the templates' internal origin. + return_internal_server_error_on_failure (bool): Whether the status + query should return HTTP 500 for failed orchestrations. """ fallback_urls = { 'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters, @@ -67,6 +70,20 @@ def __init__( url = url.replace(_INSTANCE_ID_PLACEHOLDER, encoded_instance_id) urls[name] = replace_url_origin(url, request_origin) + if return_internal_server_error_on_failure: + status_url = urlsplit(urls["statusQueryGetUri"]) + query = status_url.query + if query: + query += "&" + query += "returnInternalServerErrorOnFailure=true" + urls["statusQueryGetUri"] = urlunsplit(( + status_url.scheme, + status_url.netloc, + status_url.path, + query, + status_url.fragment, + )) + super().__init__(urls) def __str__(self) -> str: diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index fd48bd2..9ab29d7 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -206,6 +206,30 @@ async def test_create_http_management_payload_v2_signature(): await client.close() +@pytest.mark.parametrize( + "use_sync_client", + [False, True], +) +async def test_create_http_management_payload_can_request_failure_status_500( + use_sync_client): + async_client = df.DurableFunctionsClient(_CLIENT_CONFIG) + sync_client = df.SyncDurableFunctionsClient(_CLIENT_CONFIG) + client = sync_client if use_sync_client else async_client + try: + payload = client.create_http_management_payload( + _make_request(), + "inst", + return_internal_server_error_on_failure=True) + assert payload["statusQueryGetUri"] == ( + "http://localhost:7071/runtime/webhooks/durabletask/instances/" + "inst?code=xyz&returnInternalServerErrorOnFailure=true") + assert "returnInternalServerErrorOnFailure" not in ( + payload["purgeHistoryDeleteUri"]) + finally: + await async_client.close() + sync_client.close() + + async def test_create_http_management_payload_requires_instance_id(): client = _make_client() try: @@ -563,11 +587,27 @@ def _make_request() -> func.HttpRequest: method="GET", url="http://localhost:7071/api/status", body=b"") +def _make_terminal_state( + runtime_status: OrchestrationStatus, + serialized_output=None, + 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, 1, tzinfo=timezone.utc), + runtime_status=runtime_status, + serialized_input=None, + serialized_output=serialized_output, + serialized_custom_status=None, + failure_details=failure_details) + + async def test_wait_for_completion_returns_output_when_completed(): client = _make_client() try: - state = SimpleNamespace( - runtime_status=OrchestrationStatus.COMPLETED, + state = _make_terminal_state( + OrchestrationStatus.COMPLETED, serialized_output='"done"') with patch.object(client, "wait_for_orchestration_completion", new=AsyncMock(return_value=state)): @@ -580,6 +620,25 @@ async def test_wait_for_completion_returns_output_when_completed(): await client.close() +async def test_wait_for_completion_returns_status_when_terminated(): + client = _make_client() + try: + state = _make_terminal_state( + OrchestrationStatus.TERMINATED, + serialized_output='"stopped"') + with patch.object(client, "wait_for_orchestration_completion", + new=AsyncMock(return_value=state)): + with pytest.warns(DeprecationWarning): + response = await client.wait_for_completion_or_create_check_status_response( + _make_request(), "abc") + body = json.loads(response.get_body()) + assert response.status_code == 200 + assert body["runtimeStatus"] == "Terminated" + assert body["output"] == "stopped" + finally: + await client.close() + + async def test_wait_for_completion_returns_check_status_on_timeout(): client = _make_client() try: @@ -593,29 +652,49 @@ async def test_wait_for_completion_returns_check_status_on_timeout(): await client.close() -async def test_wait_for_completion_surfaces_failure_details_when_failed(): +async def test_wait_for_completion_preserves_failure_option_on_timeout(): + client = _make_client() + try: + with patch.object(client, "wait_for_orchestration_completion", + new=AsyncMock(side_effect=TimeoutError)): + with pytest.warns(DeprecationWarning): + response = await client.wait_for_completion_or_create_check_status_response( + _make_request(), + "abc", + return_internal_server_error_on_failure=True) + body = json.loads(response.get_body()) + assert response.status_code == 202 + assert body["statusQueryGetUri"].endswith( + "code=xyz&returnInternalServerErrorOnFailure=true") + assert response.headers["Location"] == body["statusQueryGetUri"] + finally: + await client.close() + + +@pytest.mark.parametrize( + ("return_internal_server_error_on_failure", "expected_status_code"), + [(False, 200), (True, 500)], +) +async def test_wait_for_completion_surfaces_failure_details_when_failed( + return_internal_server_error_on_failure, expected_status_code): client = _make_client() try: # A failed orchestration carries its error in failure_details; # serialized_output is typically None. v1 returns the full status JSON # (runtimeStatus, instanceId, timestamps, output) for terminal states. - state = SimpleNamespace( - name="orch", - instance_id="abc", - created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), - last_updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), - runtime_status=OrchestrationStatus.FAILED, - serialized_input=None, - serialized_output=None, - serialized_custom_status=None, + state = _make_terminal_state( + OrchestrationStatus.FAILED, failure_details=SimpleNamespace( message="boom", error_type="ValueError", stack_trace="tb")) with patch.object(client, "wait_for_orchestration_completion", new=AsyncMock(return_value=state)): with pytest.warns(DeprecationWarning): response = await client.wait_for_completion_or_create_check_status_response( - _make_request(), "abc") - assert response.status_code == 500 + _make_request(), + "abc", + return_internal_server_error_on_failure=( + return_internal_server_error_on_failure)) + assert response.status_code == expected_status_code body = json.loads(response.get_body()) assert body["runtimeStatus"] == "Failed" assert body["instanceId"] == "abc" @@ -625,11 +704,20 @@ async def test_wait_for_completion_surfaces_failure_details_when_failed(): await client.close() -async def test_create_check_status_response_location_includes_query_string(): - client = _make_client() +@pytest.mark.parametrize( + "use_sync_client", + [False, True], +) +async def test_create_check_status_response_has_polling_headers( + use_sync_client): + async_client = df.DurableFunctionsClient(_CLIENT_CONFIG) + sync_client = df.SyncDurableFunctionsClient(_CLIENT_CONFIG) + client = sync_client if use_sync_client else async_client try: response = client.create_check_status_response(_make_request(), "abc") assert response.status_code == 202 + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Retry-After"] == "10" location = response.headers["Location"] # The Location must carry the required query string so a client that # follows it is authorized, and it matches the body's statusQueryGetUri. @@ -637,7 +725,31 @@ async def test_create_check_status_response_location_includes_query_string(): body = json.loads(response.get_body()) assert location == body["statusQueryGetUri"] finally: - await client.close() + await async_client.close() + sync_client.close() + + +@pytest.mark.parametrize( + "use_sync_client", + [False, True], +) +async def test_create_check_status_response_can_request_failure_status_500( + use_sync_client): + async_client = df.DurableFunctionsClient(_CLIENT_CONFIG) + sync_client = df.SyncDurableFunctionsClient(_CLIENT_CONFIG) + client = sync_client if use_sync_client else async_client + try: + response = client.create_check_status_response( + _make_request(), + "abc", + return_internal_server_error_on_failure=True) + body = json.loads(response.get_body()) + assert body["statusQueryGetUri"].endswith( + "code=xyz&returnInternalServerErrorOnFailure=true") + assert response.headers["Location"] == body["statusQueryGetUri"] + finally: + await async_client.close() + sync_client.close() # --------------------------------------------------------------------------- From f0c85293fec35e31d4c44e1fd529bc379d2375b3 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 29 Jul 2026 14:26:26 -0600 Subject: [PATCH 2/2] Use canonical content type header casing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4608fda7-552e-4297-a493-96bbeac57614 --- azure-functions-durable/azure/durable_functions/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index e38d714..099e738 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -272,7 +272,7 @@ def create_check_status_response( body=str(payload), status_code=202, headers={ - 'content-type': 'application/json', + 'Content-Type': 'application/json', # Match v1: Location points at statusQueryGetUri, which includes # the required query string (webhook key / task hub / connection) # so a client that follows the header is authorized. @@ -704,7 +704,7 @@ def create_check_status_response( body=str(payload), status_code=202, headers={ - "content-type": "application/json", + "Content-Type": "application/json", "Location": payload["statusQueryGetUri"], "Retry-After": "10", },