diff --git a/releasenotes/notes/fix-is-coroutine-callable-partial-async-object-3c1f9a2b7e4d6058.yaml b/releasenotes/notes/fix-is-coroutine-callable-partial-async-object-3c1f9a2b7e4d6058.yaml new file mode 100644 index 00000000..2f7d68b7 --- /dev/null +++ b/releasenotes/notes/fix-is-coroutine-callable-partial-async-object-3c1f9a2b7e4d6058.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Detect a ``functools.partial`` wrapping a callable object whose + ``__call__`` is asynchronous as a coroutine callable. Previously such a + partial was misrouted to the synchronous retry path, which returned an + un-awaited coroutine instead of retrying the awaited call. diff --git a/tenacity/_utils.py b/tenacity/_utils.py index 195fa504..1cfddeb4 100644 --- a/tenacity/_utils.py +++ b/tenacity/_utils.py @@ -92,8 +92,12 @@ def is_coroutine_callable(call: typing.Callable[..., typing.Any]) -> bool: return False if inspect.iscoroutinefunction(call): return True - partial_call = isinstance(call, functools.partial) and call.func - dunder_call = partial_call or getattr(call, "__call__", None) # noqa: B004 + if isinstance(call, functools.partial): + # Recurse into the wrapped target so a partial over a callable *object* + # with an async ``__call__`` (not just a plain coroutine function) is + # still detected as a coroutine callable. + return is_coroutine_callable(call.func) + dunder_call = getattr(call, "__call__", None) # noqa: B004 return inspect.iscoroutinefunction(dunder_call) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 96560b07..e6ed0256 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -16,7 +16,7 @@ import inspect import unittest from collections.abc import Callable, Coroutine -from functools import wraps +from functools import partial, wraps from typing import Any, TypeVar from unittest import mock @@ -95,6 +95,25 @@ async def test_retry_using_async_retying(self) -> None: await retrying(_async_function, thing) assert thing.counter == thing.count + @asynctest + async def test_retry_partial_over_async_callable_object(self) -> None: + # A functools.partial wrapping a callable *object* with an async + # __call__ must be routed through the async path; otherwise + # AsyncRetrying calls it synchronously and stores the un-awaited + # coroutine as the result without ever running the body. + class AsyncCallable: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self) -> str: + self.calls += 1 + return "awaited-result" + + obj = AsyncCallable() + result: str = await AsyncRetrying()(partial(obj)) + assert result == "awaited-result" + assert obj.calls == 1 + @asynctest async def test_stop_after_attempt(self) -> None: thing = NoIOErrorAfterCount(2) diff --git a/tests/test_utils.py b/tests/test_utils.py index 50a0e3db..1441b75e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -24,6 +24,8 @@ def __call__(self) -> None: partial_sync_func = functools.partial(sync_func) partial_async_class = functools.partial(AsyncClass().__call__) partial_sync_class = functools.partial(SyncClass().__call__) + partial_async_instance = functools.partial(AsyncClass()) + partial_sync_instance = functools.partial(SyncClass()) partial_lambda_fn = functools.partial(lambda_fn) assert _utils.is_coroutine_callable(async_func) is True @@ -38,6 +40,8 @@ def __call__(self) -> None: assert _utils.is_coroutine_callable(partial_sync_func) is False assert _utils.is_coroutine_callable(partial_async_class) is True assert _utils.is_coroutine_callable(partial_sync_class) is False + assert _utils.is_coroutine_callable(partial_async_instance) is True + assert _utils.is_coroutine_callable(partial_sync_instance) is False assert _utils.is_coroutine_callable(partial_lambda_fn) is False