From 1f812e76e740a5fb7d3efd0c392382b76cf69e5e Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Wed, 4 Feb 2026 14:45:30 -0800 Subject: [PATCH] support async sleep for sync fn --- .../async-sleep-retrying-32de5866f5d041.yaml | 7 +++++ tenacity/__init__.py | 29 +++++++++++++++++-- tenacity/asyncio/__init__.py | 6 +++- tests/test_asyncio.py | 27 ++++++++++++++++- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml diff --git a/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml b/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml new file mode 100644 index 00000000..0ffe819f --- /dev/null +++ b/releasenotes/notes/async-sleep-retrying-32de5866f5d041.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Passing an async ``sleep`` callable (e.g. ``trio.sleep``) to ``@retry`` + now correctly uses ``AsyncRetrying``, even when the decorated function is + synchronous. Previously, the async sleep would silently not be awaited, + resulting in no delay between retries. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 7a6ee5d5..e734d6f5 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -99,6 +99,8 @@ WrappedFnReturnT = t.TypeVar("WrappedFnReturnT") WrappedFn = t.TypeVar("WrappedFn", bound=t.Callable[..., t.Any]) +P = t.ParamSpec("P") +R = t.TypeVar("R") @dataclasses.dataclass(slots=True) @@ -589,7 +591,27 @@ def retry(func: WrappedFn) -> WrappedFn: ... @t.overload def retry( - sleep: t.Callable[[t.Union[int, float]], t.Union[None, t.Awaitable[None]]] = sleep, + *, + sleep: t.Callable[[t.Union[int, float]], t.Awaitable[None]], + stop: "StopBaseT" = ..., + wait: "WaitBaseT" = ..., + retry: "t.Union[RetryBaseT, tasyncio.retry.RetryBaseT]" = ..., + before: t.Callable[["RetryCallState"], t.Union[None, t.Awaitable[None]]] = ..., + after: t.Callable[["RetryCallState"], t.Union[None, t.Awaitable[None]]] = ..., + before_sleep: t.Optional[ + t.Callable[["RetryCallState"], t.Union[None, t.Awaitable[None]]] + ] = ..., + reraise: bool = ..., + retry_error_cls: t.Type["RetryError"] = ..., + retry_error_callback: t.Optional[ + t.Callable[["RetryCallState"], t.Union[t.Any, t.Awaitable[t.Any]]] + ] = ..., +) -> t.Callable[[t.Callable[P, R | t.Awaitable[R]]], t.Callable[P, t.Awaitable[R]]]: ... + + +@t.overload +def retry( + sleep: t.Callable[[t.Union[int, float]], None] = sleep, stop: "StopBaseT" = stop_never, wait: "WaitBaseT" = wait_none(), retry: "t.Union[RetryBaseT, tasyncio.retry.RetryBaseT]" = retry_if_exception_type(), @@ -628,7 +650,10 @@ def wrap(f: WrappedFn) -> WrappedFn: f"this will probably hang indefinitely (did you mean retry={f.__class__.__name__}(...)?)" ) r: "BaseRetrying" - if _utils.is_coroutine_callable(f): + sleep = dkw.get("sleep") + if _utils.is_coroutine_callable(f) or ( + sleep is not None and _utils.is_coroutine_callable(sleep) + ): r = AsyncRetrying(*dargs, **dkw) elif ( tornado diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index a9260914..230b1154 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -107,11 +107,15 @@ async def __call__( # type: ignore[override] self.begin() retry_state = RetryCallState(retry_object=self, fn=fn, args=args, kwargs=kwargs) + is_async = _utils.is_coroutine_callable(fn) while True: do = await self.iter(retry_state=retry_state) if isinstance(do, DoAttempt): try: - result = await fn(*args, **kwargs) + if is_async: + result = await fn(*args, **kwargs) + else: + result = fn(*args, **kwargs) except BaseException: # noqa: B902 retry_state.set_exception(sys.exc_info()) # type: ignore[arg-type] else: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index f6793f0b..1620fe08 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -34,7 +34,11 @@ from tenacity import retry, retry_if_exception, retry_if_result, stop_after_attempt from tenacity.wait import wait_fixed -from .test_tenacity import NoIOErrorAfterCount, current_time_ms +from .test_tenacity import ( + NoIOErrorAfterCount, + NoneReturnUntilAfterCount, + current_time_ms, +) def asynctest(callable_): @@ -463,5 +467,26 @@ async def foo(): pass +class TestSyncFunctionWithAsyncSleep(unittest.TestCase): + @asynctest + async def test_sync_function_with_async_sleep(self): + """A sync function with an async sleep callable uses AsyncRetrying.""" + mock_sleep = mock.AsyncMock() + + thing = NoneReturnUntilAfterCount(2) + + @retry( + sleep=mock_sleep, + wait=wait_fixed(1), + retry=retry_if_result(lambda x: x is None), + ) + def sync_function(): + return thing.go() + + result = await sync_function() + assert result is True + assert mock_sleep.await_count == 2 + + if __name__ == "__main__": unittest.main()