Browse Source

🐛 Fix async wrappers using @wraps incorrectly detected as sync

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
pull/14445/head
aghabidareh 8 months ago
parent
commit
d5f1dc30fb
  1. 20
      fastapi/dependencies/models.py
  2. 65
      tests/test_async_wrapper_sync_function.py

20
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):

65
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"
Loading…
Cancel
Save