Skip to content

Commit 4cce8a8

Browse files
committed
Fix Functions client typing
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76f2163a-0773-4889-b627-1bd4e5c7bea0
1 parent 1ed37e9 commit 4cce8a8

4 files changed

Lines changed: 76 additions & 77 deletions

File tree

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

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,17 @@ def _create_http_response(status_code: int, body: Union[str, Any]) -> func.HttpR
499499
class SyncDurableFunctionsClient(TaskHubGrpcClient):
500500
"""Synchronous durable client supplied by a Functions durable-client binding."""
501501

502+
taskHubName: str
503+
connectionName: str
504+
creationUrls: dict[str, str]
505+
managementUrls: dict[str, str]
506+
baseUrl: str
507+
requiredQueryStringParameters: str
508+
rpcBaseUrl: str
509+
httpBaseUrl: str
510+
maxGrpcMessageSizeInBytes: int
511+
grpcHttpClientTimeout: timedelta | str
512+
502513
def __init__(self, client_as_string: str):
503514
self._parse_client_configuration(client_as_string)
504515
interceptors = [AzureFunctionsDefaultClientInterceptorImpl(
@@ -516,8 +527,61 @@ def __init__(self, client_as_string: str):
516527
channel_options=channel_options,
517528
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
518529

519-
_parse_client_configuration = DurableFunctionsClient._parse_client_configuration
520-
create_check_status_response = DurableFunctionsClient.create_check_status_response
521-
create_http_management_payload = DurableFunctionsClient.create_http_management_payload
522-
_get_client_response_links = DurableFunctionsClient._get_client_response_links
523-
_get_instance_status_url = DurableFunctionsClient._get_instance_status_url
530+
def _parse_client_configuration(self, client_as_string: str) -> None:
531+
client = json.loads(client_as_string)
532+
self.taskHubName = client.get("taskHubName") or ""
533+
self.connectionName = client.get("connectionName") or ""
534+
self.creationUrls = client.get("creationUrls") or {}
535+
self.managementUrls = client.get("managementUrls") or {}
536+
self.baseUrl = client.get("baseUrl") or ""
537+
self.requiredQueryStringParameters = client.get(
538+
"requiredQueryStringParameters") or ""
539+
self.rpcBaseUrl = client.get("rpcBaseUrl") or ""
540+
self.httpBaseUrl = client.get("httpBaseUrl") or ""
541+
self.maxGrpcMessageSizeInBytes = client.get(
542+
"maxGrpcMessageSizeInBytes") or 0
543+
self.grpcHttpClientTimeout = client.get(
544+
"grpcHttpClientTimeout") or timedelta(seconds=30)
545+
546+
def create_check_status_response(
547+
self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse:
548+
payload = self._get_client_response_links(request, instance_id)
549+
return func.HttpResponse(
550+
body=str(payload),
551+
status_code=202,
552+
headers={
553+
"content-type": "application/json",
554+
"Location": payload["statusQueryGetUri"],
555+
},
556+
)
557+
558+
def create_http_management_payload(
559+
self,
560+
request: func.HttpRequest | str | None = None,
561+
instance_id: str | None = None) -> HttpManagementPayload:
562+
if instance_id is None and isinstance(request, str):
563+
instance_id = request
564+
request = None
565+
if instance_id is None:
566+
raise TypeError("instance_id is required")
567+
resolved_request = request if isinstance(request, func.HttpRequest) else None
568+
return self._get_client_response_links(resolved_request, instance_id)
569+
570+
def _get_client_response_links(
571+
self, request: func.HttpRequest | None,
572+
instance_id: str) -> HttpManagementPayload:
573+
return HttpManagementPayload(
574+
instance_id,
575+
self._get_instance_status_url(request, instance_id),
576+
self.requiredQueryStringParameters)
577+
578+
def _get_instance_status_url(
579+
self, request: func.HttpRequest | None, instance_id: str) -> str:
580+
encoded_instance_id = quote(instance_id)
581+
if request is not None:
582+
request_url = urlparse(request.url)
583+
return (
584+
f"{request_url.scheme}://{request_url.netloc}"
585+
f"/runtime/webhooks/durabletask/instances/{encoded_instance_id}")
586+
base_url = self.baseUrl.rstrip("/") if self.baseUrl else ""
587+
return f"{base_url}/instances/{encoded_instance_id}"

azure-functions-durable/azure/durable_functions/decorators/durable_app.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -409,8 +409,8 @@ def attach_client_function(user_fn: Callable[..., Any]) -> FunctionBuilder:
409409
# ``FunctionBuilder`` (e.g. history export), which is left untouched.
410410
from ..client import DurableFunctionsClient, SyncDurableFunctionsClient
411411

412-
function = (user_fn._function._func if isinstance(user_fn, FunctionBuilder)
413-
else user_fn)
412+
function = (user_fn._function._func # pyright: ignore[reportPrivateUsage]
413+
if isinstance(user_fn, FunctionBuilder) else user_fn)
414414
signature = inspect.signature(function)
415415

416416
@wraps(function)
@@ -427,15 +427,15 @@ async def client_bound(*args: Any, **kwargs: Any) -> Any:
427427
result = function(*bound.args, **bound.kwargs)
428428
return await result if inspect.isawaitable(result) else result
429429
finally:
430-
if sync:
431-
client.close()
432-
else:
430+
if isinstance(client, DurableFunctionsClient):
433431
client.schedule_close()
432+
else:
433+
client.close()
434434

435435
client_bound.__annotations__[client_name] = str
436-
client_bound.client_function = function # pyright: ignore[reportFunctionMemberAccess]
436+
setattr(client_bound, "client_function", function)
437437
if isinstance(user_fn, FunctionBuilder):
438-
user_fn._function._func = client_bound
438+
user_fn._function._func = client_bound # pyright: ignore[reportPrivateUsage]
439439
return wrap(user_fn)
440440
return wrap(client_bound)
441441

azure-functions-durable/azure/durable_functions/internal/history_export_compat.py

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020

2121
from __future__ import annotations
2222

23-
import threading
2423
from collections.abc import Mapping
2524
from datetime import datetime, timezone
2625
from typing import Any, Optional, cast
@@ -48,10 +47,6 @@
4847
from durabletask.extensions.history_export.transitions import assert_valid_transition
4948
from durabletask.extensions.history_export.writer import HistoryWriter
5049

51-
from .azurefunctions_grpc_interceptor import (
52-
AzureFunctionsDefaultClientInterceptorImpl,
53-
)
54-
from .serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
5550

5651
# The activity registers under the same name the export orchestrator calls, so
5752
# it transparently replaces the core activity.
@@ -156,11 +151,6 @@ def list_terminal_instances(
156151
# ---------------------------------------------------------------------------
157152

158153
_export_writer: Optional[HistoryWriter] = None
159-
# The per-process export context (sync client + writer), built lazily from the
160-
# first injected durable client and reused across every export activity. Guarded
161-
# by ``_context_lock`` for the first-build race; its client is closed at exit.
162-
_export_context: Optional[HistoryExportContext] = None
163-
_context_lock = threading.Lock()
164154

165155

166156
def set_export_writer(writer: HistoryWriter) -> None:
@@ -174,48 +164,6 @@ def set_export_writer(writer: HistoryWriter) -> None:
174164
_export_writer = writer
175165

176166

177-
def _build_sync_client(client: Any) -> TaskHubGrpcClient:
178-
"""Build a synchronous ``TaskHubGrpcClient`` from an injected durable client.
179-
180-
The ``durable_client_input`` binding yields an async ``DurableFunctionsClient``
181-
carrying the host's RPC endpoint and auth; the export activities use the
182-
synchronous client, so this bridges to one aimed at the same endpoint.
183-
184-
> [!NOTE]
185-
> This async->sync adapter is temporary. Once a first-class synchronous
186-
> durable-client binding exists
187-
> (https://github.com/microsoft/durabletask-python/issues/181), the export
188-
> activities can be injected with a sync client directly and this bridge --
189-
> along with the separate channel it opens -- can be removed.
190-
"""
191-
interceptors = [AzureFunctionsDefaultClientInterceptorImpl(
192-
client.taskHubName, client.requiredQueryStringParameters)]
193-
return TaskHubGrpcClient(
194-
host_address=client.rpcBaseUrl,
195-
secure_channel=False,
196-
interceptors=interceptors,
197-
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
198-
199-
200-
def _close_sync_export_client() -> None:
201-
"""Close the process-wide sync export client (registered via ``atexit``).
202-
203-
The client is built once per worker process and reused across every export
204-
activity, so it lives for the app's lifetime; closing it at interpreter
205-
exit releases its gRPC channel on graceful shutdown. Idempotent and
206-
exception-safe -- shutdown must never surface an error from cleanup.
207-
"""
208-
global _export_context
209-
with _context_lock:
210-
context = _export_context
211-
_export_context = None
212-
if context is not None:
213-
try:
214-
context.client.close()
215-
except Exception:
216-
pass
217-
218-
219167
def _context_for(client: TaskHubGrpcClient) -> HistoryExportContext:
220168
"""Resolve an export context for the invocation's native sync client."""
221169
if _export_writer is None:

tests/azure-functions-durable/test_history_export_compat.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -203,20 +203,7 @@ def test_context_for_uses_invocation_sync_client(monkeypatch):
203203

204204

205205
def test_context_for_requires_configured_writer(monkeypatch):
206-
monkeypatch.setattr(hec, "_build_sync_client", lambda _c: MagicMock())
207206
monkeypatch.setattr(hec, "_export_writer", None)
208-
monkeypatch.setattr(hec, "_export_context", None)
209207

210208
with pytest.raises(RuntimeError, match="writer is not configured"):
211209
hec._context_for(object())
212-
213-
214-
def test_close_sync_export_client_swallows_errors(monkeypatch):
215-
fake_client = MagicMock()
216-
fake_client.close.side_effect = RuntimeError("boom")
217-
context = hec.HistoryExportContext(client=fake_client, writer=MagicMock())
218-
monkeypatch.setattr(hec, "_export_context", context)
219-
220-
# Cleanup at shutdown must never raise.
221-
hec._close_sync_export_client()
222-
assert hec._export_context is None

0 commit comments

Comments
 (0)