From cecac4c384def9223bb202d2dbbb8d1ce0575301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Garc=C3=ADa?= Date: Mon, 13 Jul 2026 00:31:18 +0200 Subject: [PATCH] Fix durableClient binding FunctionLoadError on Python 3.14 `_add_rich_client` makes the worker's `durableClient` binding type-check pass by forcing the client parameter's annotation to `str` (`user_code.__annotations__[parameter_name] = str`) before wrapping the user function with `@functools.wraps(user_code)`. On Python 3.14, PEP 649/749 changed how annotations are carried: `functools.WRAPPER_ASSIGNMENTS` now copies `__annotate__` instead of the `__annotations__` dict. The copied `__annotate__` lazily re-derives the *original* `DurableOrchestrationClient` annotation, overriding the `str` patch. The worker then reads the wrapper's annotation as `DurableOrchestrationClient`, `durableClient` binding validation fails, `FunctionLoadError` is raised, and the host 503s on startup. On Python <=3.13 this worked because `wraps` copied the already-patched `__annotations__` dict. Re-apply the `str` override on the wrapper after `wraps` runs, and drop `__annotate__` so both the dict-based reader and the `__annotate__`-based reader (which the worker uses on 3.14) agree. The pre-wrap patch is kept for the <=3.13 path. The rich `DurableOrchestrationClient` is still constructed at call time and passed to user code unchanged. Adds a regression test asserting the built wrapper's client-parameter annotation reads as `str` via both `__annotations__` and, on 3.14, `annotationlib.get_annotations(..., format=FORWARDREF)`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../decorators/durable_app.py | 10 ++++ tests/models/test_Decorators.py | 47 ++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index b885e4a5..aa371805 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -220,6 +220,16 @@ async def df_client_middleware(*args, **kwargs): # Invoke user code with rich DF Client binding return await user_code(*args, **kwargs) + # functools.wraps on Python 3.14 (PEP 649/749) copies __annotate__ rather than + # __annotations__, which re-derives the original client annotation and defeats the + # str override above. Re-apply it on the wrapper and drop __annotate__ so both the + # dict-based and __annotate__-based readers (the worker uses the latter on 3.14) agree. + _ann = dict(getattr(df_client_middleware, "__annotations__", {})) # materializes on 3.14 + _ann[parameter_name] = str + df_client_middleware.__annotations__ = _ann + if hasattr(df_client_middleware, "__annotate__"): + df_client_middleware.__annotate__ = None + # Todo: This feels awkward - however, there are two reasons that I can't naively implement # this in the same way as entities and orchestrators: # 1. We intentionally wrap this exported signature with @wraps, to preserve the original diff --git a/tests/models/test_Decorators.py b/tests/models/test_Decorators.py index 0c753f77..018ac424 100644 --- a/tests/models/test_Decorators.py +++ b/tests/models/test_Decorators.py @@ -1,3 +1,5 @@ +import sys + import azure.durable_functions as df import azure.functions as func import json @@ -8,6 +10,15 @@ def get_user_code(app): assert len(functions) == 1 return functions[0] + +def get_built_function(app): + """Return the built callable the worker introspects for binding type-checks. + + ``_add_rich_client`` wraps the user function; the wrapper is stored on the + indexed ``Function`` object at ``._func``. + """ + return get_user_code(app)._func + def assert_json(user_code, expected_dict): user_code_json = json.dumps(json.loads(str(user_code)), sort_keys=True) expected_json = json.dumps(expected_dict, sort_keys=True) @@ -121,4 +132,38 @@ def dummy_function(req, my_client, message): "type": "durableClient" } ] - }) \ No newline at end of file + }) + + +def test_durable_client_input_annotation_overridden_to_str(app): + """The client-binding parameter annotation reads as ``str`` on the built function. + + The worker type-checks the ``durableClient`` binding against the client + parameter's annotation. ``_add_rich_client`` forces that annotation to + ``str`` so the rich ``DurableOrchestrationClient`` object passes validation. + + On Python 3.14 (PEP 649/749) ``functools.wraps`` copies ``__annotate__`` + rather than the already-patched ``__annotations__`` dict, so the wrapper + re-derives the original ``DurableOrchestrationClient`` annotation and binding + validation fails (``FunctionLoadError`` -> host 503). This asserts the ``str`` + override survives on the wrapper the worker actually reads, via both the + dict-based reader and the ``__annotate__``-based reader used on 3.14. + """ + + @app.durable_client_input(client_name="client") + @app.route(route="orchestrators/{functionName}") + async def dummy_function(req: func.HttpRequest, + client: df.DurableOrchestrationClient): + pass + + built_fn = get_built_function(app) + + # Dict-based reader (used on <=3.13, still consulted on 3.14). + assert built_fn.__annotations__["client"] is str + + # __annotate__-based reader: what the worker uses on Python 3.14. + if sys.version_info >= (3, 14): + import annotationlib + annotations = annotationlib.get_annotations( + built_fn, format=annotationlib.Format.FORWARDREF) + assert annotations["client"] is str