Skip to content

Commit 0247ca9

Browse files
andystaplesCopilot
andcommitted
Fix canonical management payload URLs
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3ec5c077-6e8b-4cb3-916a-12bd3cb13937
1 parent 50f62e2 commit 0247ca9

4 files changed

Lines changed: 253 additions & 36 deletions

File tree

azure-functions-durable/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ avoiding repeated allocation of unused worker resources.
3232

3333
FIXED
3434

35+
- HTTP management payloads now preserve the host-provided management URL
36+
templates, include `rewindPostUri`, encode instance IDs, and use forwarded
37+
request origins consistently in asynchronous and synchronous clients.
3538
- Fixed asynchronous durable-client construction failing after an application
3639
event loop had been closed or cleared.
3740
- Prevented Durable HTTP calls from forwarding managed identity tokens,

azure-functions-durable/azure/durable_functions/client.py

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import threading
88

99
from datetime import datetime, timedelta
10-
from typing import Any, Optional, Union
10+
from typing import Any, Mapping, Optional, Union, cast
1111
from warnings import deprecated
1212
import azure.functions as func
1313
from urllib.parse import urlparse, quote
@@ -36,6 +36,68 @@
3636
_sync_client_cache_lock = threading.Lock()
3737

3838

39+
def _first_forwarded_value(value: str) -> str:
40+
return value.split(",", 1)[0].strip().strip('"')
41+
42+
43+
def _get_request_origin(request: func.HttpRequest) -> str:
44+
request_url = urlparse(request.url)
45+
proto = request_url.scheme
46+
host = request_url.netloc
47+
request_headers = cast(Mapping[str, str], request.headers)
48+
headers = {
49+
name.lower(): value for name, value in request_headers.items()
50+
}
51+
52+
forwarded = headers.get("forwarded")
53+
if forwarded:
54+
forwarded_values: dict[str, str] = {}
55+
for pair in forwarded.split(",", 1)[0].split(";"):
56+
name, separator, value = pair.partition("=")
57+
if separator:
58+
forwarded_values[name.strip().lower()] = value.strip().strip('"')
59+
60+
proto = forwarded_values.get("proto", proto)
61+
forwarded_host = forwarded_values.get("host")
62+
if forwarded_host:
63+
return f"{proto}://{forwarded_host}"
64+
65+
forwarded_proto = headers.get("x-forwarded-proto")
66+
if forwarded_proto:
67+
proto = _first_forwarded_value(forwarded_proto)
68+
69+
forwarded_host = headers.get("x-forwarded-host")
70+
if forwarded_host:
71+
host = _first_forwarded_value(forwarded_host)
72+
73+
return f"{proto}://{host}"
74+
75+
76+
def _build_http_management_payload(
77+
instance_id: str,
78+
management_urls: dict[str, str],
79+
base_url: str,
80+
required_query_string_parameters: str,
81+
request: func.HttpRequest | None) -> HttpManagementPayload:
82+
encoded_instance_id = quote(instance_id, safe="")
83+
request_origin: str | None = None
84+
if request is not None:
85+
request_origin = _get_request_origin(request)
86+
instance_status_url = (
87+
f"{request_origin}/runtime/webhooks/durabletask/instances/"
88+
f"{encoded_instance_id}")
89+
else:
90+
instance_status_url = (
91+
f"{base_url.rstrip('/')}/instances/{encoded_instance_id}")
92+
93+
return HttpManagementPayload(
94+
instance_id,
95+
instance_status_url,
96+
required_query_string_parameters,
97+
management_urls=management_urls,
98+
request_origin=request_origin)
99+
100+
39101
# Client class used for Durable Functions
40102
class DurableFunctionsClient(AsyncTaskHubGrpcClient):
41103
"""A gRPC client passed to Durable Functions durable client bindings.
@@ -215,21 +277,12 @@ def create_http_management_payload(
215277
return self._get_client_response_links(resolved_request, instance_id)
216278

217279
def _get_client_response_links(self, request: func.HttpRequest | None, instance_id: str) -> HttpManagementPayload:
218-
instance_status_url = self._get_instance_status_url(request, instance_id)
219-
return HttpManagementPayload(instance_id, instance_status_url, self.requiredQueryStringParameters)
220-
221-
def _get_instance_status_url(self, request: func.HttpRequest | None, instance_id: str) -> str:
222-
encoded_instance_id = quote(instance_id)
223-
if request is not None:
224-
request_url = urlparse(request.url)
225-
location_url = f"{request_url.scheme}://{request_url.netloc}"
226-
location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id
227-
else:
228-
# No request available (v1-style call): fall back to the base URL
229-
# supplied in the client binding configuration.
230-
base_url = self.baseUrl.rstrip("/") if self.baseUrl else ""
231-
location_url = base_url + "/instances/" + encoded_instance_id
232-
return location_url
280+
return _build_http_management_payload(
281+
instance_id,
282+
self.managementUrls,
283+
self.baseUrl,
284+
self.requiredQueryStringParameters,
285+
request)
233286

234287
# ------------------------------------------------------------------
235288
# Backwards-compatibility shims for the v1 azure-functions-durable
@@ -593,21 +646,12 @@ def create_http_management_payload(
593646
def _get_client_response_links(
594647
self, request: func.HttpRequest | None,
595648
instance_id: str) -> HttpManagementPayload:
596-
return HttpManagementPayload(
649+
return _build_http_management_payload(
597650
instance_id,
598-
self._get_instance_status_url(request, instance_id),
599-
self.requiredQueryStringParameters)
600-
601-
def _get_instance_status_url(
602-
self, request: func.HttpRequest | None, instance_id: str) -> str:
603-
encoded_instance_id = quote(instance_id)
604-
if request is not None:
605-
request_url = urlparse(request.url)
606-
return (
607-
f"{request_url.scheme}://{request_url.netloc}"
608-
f"/runtime/webhooks/durabletask/instances/{encoded_instance_id}")
609-
base_url = self.baseUrl.rstrip("/") if self.baseUrl else ""
610-
return f"{base_url}/instances/{encoded_instance_id}"
651+
self.managementUrls,
652+
self.baseUrl,
653+
self.requiredQueryStringParameters,
654+
request)
611655

612656

613657
def _close_cached_sync_clients() -> None:

azure-functions-durable/azure/durable_functions/http/http_management_payload.py

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
# Licensed under the MIT License.
33

44
import json
5-
from typing import Any
5+
from typing import Any, Mapping
6+
from urllib.parse import quote, urlsplit, urlunsplit
7+
8+
9+
_INSTANCE_ID_PLACEHOLDER = "INSTANCEID"
610

711

812
class HttpManagementPayload(dict[str, str]):
@@ -18,24 +22,52 @@ class HttpManagementPayload(dict[str, str]):
1822
via ``json.dumps(payload)``.
1923
"""
2024

21-
def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str):
25+
def __init__(
26+
self,
27+
instance_id: str,
28+
instance_status_url: str,
29+
required_query_string_parameters: str,
30+
*,
31+
management_urls: Mapping[str, str] | None = None,
32+
request_origin: str | None = None):
2233
"""Initializes the HttpManagementPayload with the necessary URLs.
2334
2435
Args:
2536
instance_id (str): The ID of the Durable Function instance.
2637
instance_status_url (str): The base URL for the instance status.
2738
required_query_string_parameters (str): The required URL parameters provided by the Durable extension.
39+
management_urls (Mapping[str, str] | None): Canonical URL templates
40+
provided by the Durable extension.
41+
request_origin (str | None): Externally visible request origin used
42+
to replace the templates' internal origin.
2843
"""
29-
super().__init__({
30-
'id': instance_id,
44+
fallback_urls = {
3145
'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters,
3246
'restartPostUri': instance_status_url + "/restart?" + required_query_string_parameters,
3347
'sendEventPostUri': instance_status_url + "/raiseEvent/{eventName}?" + required_query_string_parameters,
3448
'statusQueryGetUri': instance_status_url + "?" + required_query_string_parameters,
3549
'terminatePostUri': instance_status_url + "/terminate?reason={text}&" + required_query_string_parameters,
50+
'rewindPostUri': instance_status_url + "/rewind?reason={text}&" + required_query_string_parameters,
3651
'resumePostUri': instance_status_url + "/resume?reason={text}&" + required_query_string_parameters,
37-
'suspendPostUri': instance_status_url + "/suspend?reason={text}&" + required_query_string_parameters
38-
})
52+
'suspendPostUri': instance_status_url + "/suspend?reason={text}&" + required_query_string_parameters,
53+
}
54+
templates = management_urls or {}
55+
placeholder = templates.get("id") or _INSTANCE_ID_PLACEHOLDER
56+
encoded_instance_id = quote(instance_id, safe="")
57+
58+
urls = {'id': instance_id}
59+
for name, fallback_url in fallback_urls.items():
60+
template = templates.get(name)
61+
if not template:
62+
urls[name] = fallback_url
63+
continue
64+
65+
url = template.replace(placeholder, encoded_instance_id)
66+
if placeholder != _INSTANCE_ID_PLACEHOLDER:
67+
url = url.replace(_INSTANCE_ID_PLACEHOLDER, encoded_instance_id)
68+
urls[name] = _replace_origin(url, request_origin)
69+
70+
super().__init__(urls)
3971

4072
def __str__(self) -> str:
4173
return json.dumps(self)
@@ -48,3 +80,21 @@ def urls(self) -> dict[str, Any]:
4880
def to_json(self) -> dict[str, Any]:
4981
"""Return the management URLs as a plain ``dict``."""
5082
return dict(self)
83+
84+
85+
def _replace_origin(url: str, request_origin: str | None) -> str:
86+
if request_origin is None:
87+
return url
88+
89+
parsed_url = urlsplit(url)
90+
parsed_origin = urlsplit(request_origin)
91+
if not parsed_url.scheme or not parsed_url.netloc:
92+
return url
93+
94+
return urlunsplit((
95+
parsed_origin.scheme,
96+
parsed_origin.netloc,
97+
parsed_url.path,
98+
parsed_url.query,
99+
parsed_url.fragment,
100+
))

tests/azure-functions-durable/test_client_compat.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,50 @@
2929
"managementUrls": {"id": "INSTANCEID"},
3030
})
3131

32+
_MANAGEMENT_QUERY = "taskHub=HostHub&connection=HostStorage&code=host-key"
33+
_MANAGEMENT_URLS = {
34+
"id": "INSTANCEID",
35+
"statusQueryGetUri": (
36+
"http://internal-host/custom/manage/INSTANCEID?"
37+
f"{_MANAGEMENT_QUERY}"),
38+
"sendEventPostUri": (
39+
"http://internal-host/custom/manage/INSTANCEID/raiseEvent/{eventName}?"
40+
f"{_MANAGEMENT_QUERY}"),
41+
"terminatePostUri": (
42+
"http://internal-host/custom/manage/INSTANCEID/terminate?reason={text}&"
43+
f"{_MANAGEMENT_QUERY}"),
44+
"rewindPostUri": (
45+
"http://internal-host/custom/manage/INSTANCEID/rewind?reason={text}&"
46+
f"{_MANAGEMENT_QUERY}"),
47+
"purgeHistoryDeleteUri": (
48+
"http://internal-host/custom/manage/INSTANCEID?"
49+
f"{_MANAGEMENT_QUERY}"),
50+
"restartPostUri": (
51+
"http://internal-host/custom/manage/INSTANCEID/restart?"
52+
f"{_MANAGEMENT_QUERY}"),
53+
"suspendPostUri": (
54+
"http://internal-host/custom/manage/INSTANCEID/suspend?reason={text}&"
55+
f"{_MANAGEMENT_QUERY}"),
56+
"resumePostUri": (
57+
"http://internal-host/custom/manage/INSTANCEID/resume?reason={text}&"
58+
f"{_MANAGEMENT_QUERY}"),
59+
}
60+
3261

3362
def _make_client() -> df.DurableFunctionsClient:
3463
return df.DurableFunctionsClient(_CLIENT_CONFIG)
3564

3665

66+
def _make_template_config() -> str:
67+
return json.dumps({
68+
"taskHubName": "TestHub",
69+
"requiredQueryStringParameters": "code=fallback-key",
70+
"baseUrl": "http://fallback/runtime/webhooks/durabletask",
71+
"rpcBaseUrl": "http://localhost:8080/",
72+
"managementUrls": _MANAGEMENT_URLS,
73+
})
74+
75+
3776
def test_client_handles_null_max_grpc_message_size():
3877
# The Durable Functions host may send ``maxGrpcMessageSizeInBytes``
3978
# explicitly as ``null`` (not just omit it). ``dict.get(key, 0)`` returns
@@ -160,6 +199,85 @@ async def test_create_http_management_payload_requires_instance_id():
160199
await client.close()
161200

162201

202+
@pytest.mark.parametrize(
203+
("headers", "expected_origin"),
204+
[
205+
({}, "http://request-internal:7071"),
206+
({"Forwarded": 'for=10.0.0.1;proto=https;host="public.example:8443"'},
207+
"https://public.example:8443"),
208+
({"X-Forwarded-Proto": "https", "X-Forwarded-Host": "proxy.example"},
209+
"https://proxy.example"),
210+
],
211+
)
212+
async def test_management_payload_uses_host_templates_and_external_origin(
213+
headers, expected_origin):
214+
config = _make_template_config()
215+
async_client = df.DurableFunctionsClient(config)
216+
sync_client = df.SyncDurableFunctionsClient(config)
217+
request = func.HttpRequest(
218+
method="POST",
219+
url="http://request-internal:7071/api/start",
220+
headers=headers,
221+
body=b"")
222+
instance_id = "folder/instance ?"
223+
encoded_instance_id = "folder%2Finstance%20%3F"
224+
225+
try:
226+
async_payload = async_client.create_http_management_payload(
227+
request, instance_id)
228+
sync_payload = sync_client.create_http_management_payload(
229+
request, instance_id)
230+
231+
assert async_payload == sync_payload
232+
assert async_payload["id"] == instance_id
233+
assert async_payload["statusQueryGetUri"] == (
234+
f"{expected_origin}/custom/manage/{encoded_instance_id}?"
235+
f"{_MANAGEMENT_QUERY}")
236+
assert async_payload["sendEventPostUri"] == (
237+
f"{expected_origin}/custom/manage/{encoded_instance_id}/"
238+
f"raiseEvent/{{eventName}}?{_MANAGEMENT_QUERY}")
239+
assert async_payload["terminatePostUri"] == (
240+
f"{expected_origin}/custom/manage/{encoded_instance_id}/"
241+
f"terminate?reason={{text}}&{_MANAGEMENT_QUERY}")
242+
assert async_payload["rewindPostUri"] == (
243+
f"{expected_origin}/custom/manage/{encoded_instance_id}/"
244+
f"rewind?reason={{text}}&{_MANAGEMENT_QUERY}")
245+
assert async_payload["purgeHistoryDeleteUri"] == (
246+
async_payload["statusQueryGetUri"])
247+
assert async_payload["restartPostUri"] == (
248+
f"{expected_origin}/custom/manage/{encoded_instance_id}/"
249+
f"restart?{_MANAGEMENT_QUERY}")
250+
assert async_payload["suspendPostUri"] == (
251+
f"{expected_origin}/custom/manage/{encoded_instance_id}/"
252+
f"suspend?reason={{text}}&{_MANAGEMENT_QUERY}")
253+
assert async_payload["resumePostUri"] == (
254+
f"{expected_origin}/custom/manage/{encoded_instance_id}/"
255+
f"resume?reason={{text}}&{_MANAGEMENT_QUERY}")
256+
assert async_payload.urls == async_payload.to_json()
257+
258+
async_response = async_client.create_check_status_response(
259+
request, instance_id)
260+
sync_response = sync_client.create_check_status_response(
261+
request, instance_id)
262+
assert json.loads(async_response.get_body()) == async_payload
263+
assert json.loads(sync_response.get_body()) == sync_payload
264+
assert json.loads(async_response.get_body())["rewindPostUri"] == (
265+
async_payload["rewindPostUri"])
266+
finally:
267+
await async_client.close()
268+
sync_client.close()
269+
270+
271+
async def test_management_payload_without_request_preserves_template_origin():
272+
client = df.DurableFunctionsClient(_make_template_config())
273+
try:
274+
payload = client.create_http_management_payload("instance")
275+
assert payload["statusQueryGetUri"] == (
276+
f"http://internal-host/custom/manage/instance?{_MANAGEMENT_QUERY}")
277+
finally:
278+
await client.close()
279+
280+
163281
# ---------------------------------------------------------------------------
164282
# Deprecated client method aliases
165283
# ---------------------------------------------------------------------------
@@ -659,6 +777,8 @@ async def test_http_management_payload_is_mapping_like():
659777
payload = client.create_http_management_payload("inst1")
660778
assert payload["id"] == "inst1"
661779
assert "statusQueryGetUri" in payload
780+
assert "rewindPostUri" in payload
781+
assert payload.urls["rewindPostUri"] == payload.to_json()["rewindPostUri"]
662782
assert "id" in list(payload.keys())
663783
assert dict(payload.items())["id"] == "inst1"
664784
finally:

0 commit comments

Comments
 (0)