From d5f1dc30fb1510c79e97c5895c23c5258a76195b Mon Sep 17 00:00:00 2001 From: aghabidareh Date: Wed, 3 Dec 2025 21:54:51 +0330 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20async=20wrappers=20using?= =?UTF-8?q?=20@wraps=20incorrectly=20detected=20as=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a decorator uses `@wraps(func)` on an async wrapper around a sync function, FastAPI incorrectly treats the handler as sync because `is_coroutine_callable` uses `inspect.unwrap()` which follows `__wrapped__` back to the original sync function. This fix checks the actual callable first (not the unwrapped version) to correctly detect if the wrapper itself is async/generator, and only falls back to the unwrapped version for cases like `partial`. Fixes #14444 --- fastapi/dependencies/models.py | 20 +++++++ tests/test_async_wrapper_sync_function.py | 65 +++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/test_async_wrapper_sync_function.py diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py index 2a4d9a010..728af15b8 100644 --- a/fastapi/dependencies/models.py +++ b/fastapi/dependencies/models.py @@ -86,6 +86,12 @@ class Dependant: @cached_property def is_gen_callable(self) -> bool: + if self.call is not None: + if inspect.isgeneratorfunction(self.call): + return True + call_dunder = getattr(self.call, "__call__", None) # noqa: B004 + if inspect.isgeneratorfunction(call_dunder): + return True if inspect.isgeneratorfunction(self._unwrapped_call): return True dunder_call = getattr(self._unwrapped_call, "__call__", None) # noqa: B004 @@ -93,6 +99,12 @@ class Dependant: @cached_property def is_async_gen_callable(self) -> bool: + if self.call is not None: + if inspect.isasyncgenfunction(self.call): + return True + call_dunder = getattr(self.call, "__call__", None) # noqa: B004 + if inspect.isasyncgenfunction(call_dunder): + return True if inspect.isasyncgenfunction(self._unwrapped_call): return True dunder_call = getattr(self._unwrapped_call, "__call__", None) # noqa: B004 @@ -100,6 +112,14 @@ class Dependant: @cached_property def is_coroutine_callable(self) -> bool: + if self.call is not None: + if inspect.isroutine(self.call): + if iscoroutinefunction(self.call): + return True + else: + call_dunder = getattr(self.call, "__call__", None) # noqa: B004 + if iscoroutinefunction(call_dunder): + return True if inspect.isroutine(self._unwrapped_call): return iscoroutinefunction(self._unwrapped_call) if inspect.isclass(self._unwrapped_call): diff --git a/tests/test_async_wrapper_sync_function.py b/tests/test_async_wrapper_sync_function.py new file mode 100644 index 000000000..24dffcfe4 --- /dev/null +++ b/tests/test_async_wrapper_sync_function.py @@ -0,0 +1,65 @@ +"""Test for async wrapper around sync function. + +This tests the issue reported in #14442 where using functools.wraps on an async +wrapper around a sync function causes FastAPI to incorrectly treat the handler +as sync, resulting in: ValueError: [TypeError("'coroutine' object is not iterable"), ...] + +The issue is that Dependant.is_coroutine_callable uses inspect.unwrap() which follows +__wrapped__ back to the original sync function, instead of checking if the actual +registered handler is async. +""" + +from functools import wraps + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + + +def async_wrap(func): + """A decorator that wraps a sync function with an async handler.""" + + @wraps(func) + async def wrapper(*args, **kwargs): + func(*args, **kwargs) + return "OK" + + return wrapper + + +app = FastAPI() + + +@app.get("/") +@async_wrap +def index(): + """A simple sync page function wrapped with async handler.""" + print("Hello!") + + +def sync_dependency(): + return "dep_value" + + +@app.get("/with-dependency") +@async_wrap +def with_dependency(value: str = Depends(sync_dependency)): + """A sync function with dependency, wrapped with async handler.""" + print(f"Got: {value}") + + +client = TestClient(app) + + +def test_async_wrapper_around_sync_function(): + """Test that async wrapper around sync function works correctly.""" + response = client.get("/") + assert response.status_code == 200, response.text + assert response.json() == "OK" + + +def test_async_wrapper_with_dependency(): + """Test that async wrapper with dependency works correctly.""" + response = client.get("/with-dependency") + assert response.status_code == 200, response.text + assert response.json() == "OK"