From 9e9c13be13b2a89446dda0263f18260c0f4fc088 Mon Sep 17 00:00:00 2001 From: chuenchen309 <48723787+chuenchen309@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:10:08 +0800 Subject: [PATCH 1/2] Fix is_coroutine_callable for a partial over an async callable object is_coroutine_callable set `dunder_call = call.func` for a functools.partial and then ran iscoroutinefunction() on it. When call.func is a callable *object* whose __call__ is async (rather than a plain coroutine function), iscoroutinefunction() inspects the instance and not its __call__, so the partial was reported as not-async. The same object without the partial is detected correctly via the __call__ branch, so `partial(...)` silently broke a case that otherwise worked: retry(...)(functools.partial(async_callable_obj, ...)) was dispatched to the synchronous Retrying, called the coroutine without awaiting it, and returned a leaked, un-awaited coroutine. Recurse into the partial's target instead, which also handles nested partials and keeps the existing partial-over-coroutine-function and callable-object cases working. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ne-callable-partial-async-object-3c1f9a2b7e4d6058.yaml | 7 +++++++ tenacity/_utils.py | 8 ++++++-- tests/test_utils.py | 4 ++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/fix-is-coroutine-callable-partial-async-object-3c1f9a2b7e4d6058.yaml 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_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 From 1f82efd02e9fd218654cf72d78b3dd56063bc7ab Mon Sep 17 00:00:00 2001 From: chuenchen309 <48723787+chuenchen309@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:34:05 +0800 Subject: [PATCH 2/2] Add behavioral AsyncRetrying test for partial over async callable object Exercises the actual retry path: without the fix, AsyncRetrying routes the partial to the sync branch and stores the un-awaited coroutine as the result (asserted to fail), rather than only unit-testing is_coroutine_callable. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_asyncio.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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)