Skip to content

Commit dbebdb4

Browse files
committed
Harden async client error handling
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76f2163a-0773-4889-b627-1bd4e5c7bea0
1 parent 53fcb26 commit dbebdb4

4 files changed

Lines changed: 124 additions & 16 deletions

File tree

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

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,10 @@ def attach_client_function(user_fn: Callable[..., Any]) -> FunctionBuilder:
408408
if isinstance(user_fn, FunctionBuilder) else user_fn)
409409
signature = inspect.signature(function)
410410

411-
@wraps(function)
412-
async def client_bound(*args: Any, **kwargs: Any) -> Any:
411+
def bind_client(
412+
args: tuple[Any, ...],
413+
kwargs: dict[str, Any],
414+
) -> tuple[inspect.BoundArguments, DurableFunctionsClient | SyncDurableFunctionsClient]:
413415
bound = signature.bind(*args, **kwargs)
414416
if client_name not in bound.arguments:
415417
raise TypeError(
@@ -422,15 +424,35 @@ async def client_bound(*args: Any, **kwargs: Any) -> Any:
422424
client = (SyncDurableFunctionsClient.get_cached(raw_client) if sync
423425
else DurableFunctionsClient(raw_client))
424426
bound.arguments[client_name] = client
425-
try:
426-
result = function(*bound.args, **bound.kwargs)
427-
return await result if inspect.isawaitable(result) else result
428-
finally:
429-
if isinstance(client, DurableFunctionsClient):
430-
client.schedule_close()
431-
432-
client_bound.__annotations__[client_name] = str
433-
setattr(client_bound, "client_function", function)
427+
return bound, client
428+
429+
def set_client_metadata(client_bound: Callable[..., Any]) -> None:
430+
annotations = dict(function.__annotations__)
431+
annotations.setdefault(client_name, str)
432+
client_bound.__annotations__ = annotations
433+
setattr(client_bound, "client_function", function)
434+
435+
if inspect.iscoroutinefunction(function):
436+
@wraps(function)
437+
async def client_bound(*args: Any, **kwargs: Any) -> Any:
438+
bound, client = bind_client(args, kwargs)
439+
try:
440+
result = function(*bound.args, **bound.kwargs)
441+
return await result
442+
finally:
443+
if isinstance(client, DurableFunctionsClient):
444+
client.schedule_close()
445+
else:
446+
@wraps(function)
447+
def client_bound(*args: Any, **kwargs: Any) -> Any:
448+
bound, client = bind_client(args, kwargs)
449+
try:
450+
return function(*bound.args, **bound.kwargs)
451+
finally:
452+
if isinstance(client, DurableFunctionsClient):
453+
client.schedule_close()
454+
455+
set_client_metadata(client_bound)
434456
if isinstance(user_fn, FunctionBuilder):
435457
user_fn._function._func = client_bound # pyright: ignore[reportPrivateUsage]
436458
return wrap(user_fn)

tests/azure-functions-durable/test_decorator_compat.py

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

44
import azure.durable_functions as df
5+
import inspect
56
import pytest
67
from azure.durable_functions.constants import (
78
ACTIVITY_TRIGGER,
@@ -134,7 +135,7 @@ async def starter(client):
134135
assert await fb._function._func(client="{}") == "DurableFunctionsClient"
135136

136137

137-
async def test_durable_client_input_sync_injects_sync_client():
138+
def test_durable_client_input_sync_injects_sync_client():
138139
app = df.DFApp()
139140
clients = []
140141

@@ -143,8 +144,9 @@ def starter(client):
143144
return type(client).__name__
144145

145146
fb = app.durable_client_input_sync(client_name="client")(starter)
146-
assert await fb._function._func(client="{}") == "SyncDurableFunctionsClient"
147-
assert await fb._function._func(client="{}") == "SyncDurableFunctionsClient"
147+
assert not inspect.iscoroutinefunction(fb._function._func)
148+
assert fb._function._func(client="{}") == "SyncDurableFunctionsClient"
149+
assert fb._function._func(client="{}") == "SyncDurableFunctionsClient"
148150
assert clients[0] is clients[1]
149151

150152

@@ -159,6 +161,17 @@ async def starter(other):
159161
await fb._function._func(other="{}")
160162

161163

164+
async def test_durable_client_input_preserves_client_annotation():
165+
app = df.DFApp()
166+
167+
async def starter(client: df.DurableFunctionsClient):
168+
return type(client).__name__
169+
170+
fb = app.durable_client_input(client_name="client")(starter)
171+
assert starter.__annotations__["client"] is df.DurableFunctionsClient
172+
assert await fb._function._func(client="{}") == "DurableFunctionsClient"
173+
174+
162175
# ---------------------------------------------------------------------------
163176
# All decorators register a function builder
164177
# ---------------------------------------------------------------------------

tests/durabletask/extensions/history_export/test_client.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from datetime import datetime, timedelta, timezone
1818

1919
import pytest
20+
import grpc
2021

2122
from durabletask import client, task, worker
2223
from durabletask.extensions.history_export import (
@@ -192,6 +193,47 @@ async def test_async_client_get_job_returns_none_for_empty_state(writer):
192193
assert await export_client.get_job("empty-state") is None
193194

194195

196+
async def test_async_wait_for_job_raises_lookup_when_job_never_exists(writer):
197+
export_client = AsyncExportHistoryClient(MagicMock(), writer)
198+
export_client.get_job = AsyncMock(return_value=None)
199+
200+
with pytest.raises(ExportJobNotFoundError):
201+
await export_client.wait_for_job(
202+
"never-created", timeout=0.01, poll_interval=0.001)
203+
204+
205+
async def test_async_wait_for_job_times_out_when_status_stays_active(writer):
206+
export_client = AsyncExportHistoryClient(MagicMock(), writer)
207+
export_client.get_job = AsyncMock(
208+
return_value=MagicMock(status=ExportJobStatus.ACTIVE))
209+
210+
with pytest.raises(TimeoutError, match="did not reach a terminal status"):
211+
await export_client.wait_for_job(
212+
"stuck-job", timeout=0.01, poll_interval=0.001)
213+
214+
215+
class _NotFoundRpcError(grpc.RpcError):
216+
def code(self):
217+
return grpc.StatusCode.NOT_FOUND
218+
219+
220+
async def test_async_delete_job_ignores_not_found_errors(writer):
221+
dt_client = MagicMock()
222+
dt_client.signal_entity = AsyncMock()
223+
dt_client.terminate_orchestration = AsyncMock(side_effect=_NotFoundRpcError())
224+
dt_client.wait_for_orchestration_completion = AsyncMock(
225+
side_effect=_NotFoundRpcError())
226+
dt_client.purge_orchestration = AsyncMock(side_effect=_NotFoundRpcError())
227+
export_client = AsyncExportHistoryClient(dt_client, writer)
228+
229+
await export_client.delete_job("missing-job")
230+
231+
dt_client.signal_entity.assert_awaited_once()
232+
dt_client.terminate_orchestration.assert_awaited_once()
233+
dt_client.wait_for_orchestration_completion.assert_awaited_once()
234+
dt_client.purge_orchestration.assert_awaited_once()
235+
236+
195237
def test_get_job_returns_none_for_unknown_id(export_client):
196238
assert export_client.get_job("does-not-exist") is None
197239

tests/durabletask/scheduled/test_client_filter_and_capability.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
"""Unit tests for ScheduleClient filtering and scheduled-tasks worker capability."""
55

66
from datetime import datetime, timedelta, timezone
7+
from types import SimpleNamespace
8+
from unittest.mock import AsyncMock, MagicMock
79

10+
import pytest
811
import durabletask.internal.orchestrator_service_pb2 as pb
9-
from durabletask.scheduled.client import ScheduledTaskClient
12+
from durabletask.client import OrchestrationStatus
13+
from durabletask.scheduled.client import AsyncScheduleClient, ScheduledTaskClient
1014
from durabletask.scheduled.models import (ScheduleConfiguration,
1115
ScheduleCreationOptions, ScheduleQuery,
1216
ScheduleState)
@@ -76,8 +80,35 @@ def test_capability_absent_by_default(self):
7680
assert pb.WORKER_CAPABILITY_SCHEDULED_TASKS not in worker._capabilities # pyright: ignore[reportPrivateUsage]
7781

7882
def test_add_capability_rejected_while_running(self):
79-
import pytest
8083
worker = TaskHubGrpcWorker()
8184
worker._is_running = True # pyright: ignore[reportPrivateUsage]
8285
with pytest.raises(RuntimeError):
8386
worker.add_capability(pb.WORKER_CAPABILITY_SCHEDULED_TASKS)
87+
88+
89+
async def test_async_schedule_operation_raises_failure_details():
90+
client = MagicMock()
91+
client.schedule_new_orchestration = AsyncMock(return_value="operation-id")
92+
client.wait_for_orchestration_completion = AsyncMock(return_value=SimpleNamespace(
93+
runtime_status=OrchestrationStatus.FAILED,
94+
failure_details=SimpleNamespace(message="operation failed"),
95+
))
96+
schedule = AsyncScheduleClient(client, "schedule-id")
97+
98+
with pytest.raises(RuntimeError, match="operation failed"):
99+
await schedule.create(ScheduleCreationOptions(
100+
schedule_id="schedule-id",
101+
orchestration_name="orchestration",
102+
interval=timedelta(minutes=1),
103+
))
104+
105+
106+
async def test_async_schedule_operation_propagates_timeout():
107+
client = MagicMock()
108+
client.schedule_new_orchestration = AsyncMock(return_value="operation-id")
109+
client.wait_for_orchestration_completion = AsyncMock(
110+
side_effect=TimeoutError("operation timed out"))
111+
schedule = AsyncScheduleClient(client, "schedule-id")
112+
113+
with pytest.raises(TimeoutError, match="operation timed out"):
114+
await schedule.pause()

0 commit comments

Comments
 (0)