From a7abd72862d24741aabc02d0ec98011394c7dbea Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Sat, 25 Jul 2026 18:20:22 +0530 Subject: [PATCH 1/5] fix: never retry CancelledError under retry_if_not_exception_type retry_if_not_exception_type(T) treated every non-T exception as retryable, including asyncio.CancelledError. That broke asyncio.wait_for / timeout cancellation: the task was retried instead of stopping. Exclude control-flow exceptions (CancelledError, KeyboardInterrupt, SystemExit, GeneratorExit) before applying the user type filter. Same guard on retry_unless_exception_type. Async + sync regression tests. Fixes #529. --- tenacity/retry.py | 29 ++++++++++++++++++++- tests/test_asyncio.py | 57 ++++++++++++++++++++++++++++++++++++++++++ tests/test_tenacity.py | 19 ++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/tenacity/retry.py b/tenacity/retry.py index e59f4c0a..7ef6a818 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -114,8 +114,31 @@ def _check(self, e: BaseException) -> bool: return isinstance(e, self.exception_types) +def _is_control_flow_exception(e: BaseException) -> bool: + """Return True for exceptions that must never trigger a retry. + + Task cancellation (``asyncio.CancelledError``), keyboard interrupt, and + interpreter exit are control-flow signals — not transient failures. + ``CancelledError`` is a bare ``BaseException`` on Python 3.9+; treating it + like a normal error breaks ``asyncio.wait_for`` / ``asyncio.timeout`` + (see #529). + """ + if isinstance(e, (KeyboardInterrupt, SystemExit, GeneratorExit)): + return True + try: + import asyncio + except ImportError: # pragma: no cover + return False + return isinstance(e, asyncio.CancelledError) + + class retry_if_not_exception_type(retry_if_exception): - """Retries except an exception has been raised of one or more types.""" + """Retries except an exception has been raised of one or more types. + + Control-flow exceptions (``CancelledError``, ``KeyboardInterrupt``, + ``SystemExit``, ``GeneratorExit``) are never retried, even when they are + not listed in ``exception_types``. + """ def __init__( self, @@ -126,6 +149,8 @@ def __init__( super().__init__(self._check) def _check(self, e: BaseException) -> bool: + if _is_control_flow_exception(e): + return False return not isinstance(e, self.exception_types) @@ -141,6 +166,8 @@ def __init__( super().__init__(self._check) def _check(self, e: BaseException) -> bool: + if _is_control_flow_exception(e): + return False return not isinstance(e, self.exception_types) def __call__(self, retry_state: "RetryCallState") -> bool: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 96560b07..bc7be0d6 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -36,6 +36,7 @@ RetryError, retry, retry_if_exception, + retry_if_not_exception_type, retry_if_result, stop_after_attempt, ) @@ -84,6 +85,62 @@ async def test_retry(self) -> None: assert thing.counter == thing.count @asynctest + + + @asynctest + async def test_wait_for_not_retried_with_retry_if_not_exception_type(self) -> None: + """CancelledError must propagate under retry_if_not_exception_type (#529). + + Default retry only retries ``Exception`` subclasses, so cancellation + already works. The bug is specific to ``retry_if_not_exception_type``, + which treated *any* non-listed exception — including CancelledError — + as retryable, breaking ``asyncio.wait_for``. + """ + attempts = 0 + + @retry( + wait=wait_fixed(0.01), + stop=stop_after_attempt(5), + reraise=True, + retry=retry_if_not_exception_type(ValueError), + ) + async def sleepy() -> None: + nonlocal attempts + attempts += 1 + await asyncio.sleep(10) + + # On 3.11+ wait_for raises TimeoutError after cancelling the task. + # The critical assertion is attempts==1 (no retry of cancellation). + with self.assertRaises((asyncio.TimeoutError, asyncio.CancelledError)): + await asyncio.wait_for(sleepy(), timeout=0.05) + + # One attempt only — cancellation must not be retried. + self.assertEqual(attempts, 1) + + @asynctest + async def test_cancelled_error_not_retried_even_if_listed_as_exception(self) -> None: + """Even if someone passes BaseException broadly, cancel still wins. + + ``retry_if_not_exception_type`` excludes control-flow exceptions + before applying the user type filter. + """ + attempts = 0 + + @retry( + wait=wait_fixed(0.01), + stop=stop_after_attempt(5), + reraise=True, + retry=retry_if_not_exception_type(RuntimeError), + ) + async def sleepy() -> None: + nonlocal attempts + attempts += 1 + raise asyncio.CancelledError() + + with self.assertRaises(asyncio.CancelledError): + await sleepy() + self.assertEqual(attempts, 1) + async def test_iscoroutinefunction(self) -> None: assert asyncio.iscoroutinefunction(_retryable_coroutine) assert inspect.iscoroutinefunction(_retryable_coroutine) diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index ba44b0c8..0beebcb0 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1390,6 +1390,25 @@ def test_retry_if_exception_message(self) -> None: print(_retryable_test_if_exception_message_message.statistics) self.fail("CustomError should've been retried from errormessage") + + def test_retry_if_not_exception_type_skips_control_flow(self) -> None: + """BaseException control-flow must not be retried (#529).""" + calls = {"n": 0} + + def boom() -> None: + calls["n"] += 1 + raise KeyboardInterrupt() + + r = tenacity.Retrying( + wait=tenacity.wait_fixed(0), + stop=tenacity.stop_after_attempt(3), + reraise=True, + retry=tenacity.retry_if_not_exception_type(ValueError), + ) + with self.assertRaises(KeyboardInterrupt): + r(boom) + self.assertEqual(calls["n"], 1) + def test_retry_if_not_exception_message(self) -> None: try: self.assertTrue( From 8d6a3b2ef6fd8cb90b5d4500ad1931cbddd41272 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Sat, 25 Jul 2026 18:29:11 +0530 Subject: [PATCH 2/5] test: fix ruff RSE102 on control-flow exception raises --- tests/test_asyncio.py | 2 +- tests/test_tenacity.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index bc7be0d6..4e57f385 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -135,7 +135,7 @@ async def test_cancelled_error_not_retried_even_if_listed_as_exception(self) -> async def sleepy() -> None: nonlocal attempts attempts += 1 - raise asyncio.CancelledError() + raise asyncio.CancelledError with self.assertRaises(asyncio.CancelledError): await sleepy() diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 0beebcb0..c6631494 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1397,7 +1397,7 @@ def test_retry_if_not_exception_type_skips_control_flow(self) -> None: def boom() -> None: calls["n"] += 1 - raise KeyboardInterrupt() + raise KeyboardInterrupt r = tenacity.Retrying( wait=tenacity.wait_fixed(0), From 618b51dff61365ac4eae280964af2c5dedfb2249 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Sat, 25 Jul 2026 18:46:41 +0530 Subject: [PATCH 3/5] style: put None at end of type unions (RUF036) Required for green CI under current ruff: preview RUF036 flags None | X unions in public retry signatures. Purely mechanical reordering; no behavior change. --- tenacity/__init__.py | 20 ++++++++++---------- tenacity/asyncio/__init__.py | 8 ++++---- tenacity/retry.py | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 6b591464..c179d10d 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -272,13 +272,13 @@ def copy( retry: retry_base | object = _unset, before: t.Callable[["RetryCallState"], None] | object = _unset, after: t.Callable[["RetryCallState"], None] | object = _unset, - before_sleep: t.Callable[["RetryCallState"], None] | None | object = _unset, + before_sleep: t.Callable[["RetryCallState"], None] | object | None = _unset, reraise: bool | object = _unset, retry_error_cls: type[RetryError] | object = _unset, retry_error_callback: t.Callable[["RetryCallState"], t.Any] - | None - | object = _unset, - name: str | None | object = _unset, + | object + | None = _unset, + name: str | object | None = _unset, enabled: bool | object = _unset, ) -> "Self": """Copy this object with some parameters changed if needed.""" @@ -701,9 +701,9 @@ def retry( stop: "StopBaseT" = ..., wait: "WaitBaseT" = ..., retry: "RetryBaseT | tasyncio.retry.RetryBaseT" = ..., - before: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = ..., - after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = ..., - before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]] | None = ..., + before: t.Callable[["RetryCallState"], t.Awaitable[None] | None] = ..., + after: t.Callable[["RetryCallState"], t.Awaitable[None] | None] = ..., + before_sleep: t.Callable[["RetryCallState"], t.Awaitable[None] | None] | None = ..., reraise: bool = ..., retry_error_cls: type["RetryError"] = ..., retry_error_callback: t.Callable[["RetryCallState"], t.Any | t.Awaitable[t.Any]] @@ -718,9 +718,9 @@ def retry( stop: "StopBaseT" = stop_never, wait: "WaitBaseT" = wait_none(), retry: "RetryBaseT | tasyncio.retry.RetryBaseT" = retry_if_exception_type(), - before: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = before_nothing, - after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = after_nothing, - before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]] + before: t.Callable[["RetryCallState"], t.Awaitable[None] | None] = before_nothing, + after: t.Callable[["RetryCallState"], t.Awaitable[None] | None] = after_nothing, + before_sleep: t.Callable[["RetryCallState"], t.Awaitable[None] | None] | None = None, reraise: bool = False, retry_error_cls: type["RetryError"] = RetryError, diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index a20edc2f..6291b02f 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -75,16 +75,16 @@ class AsyncRetrying(BaseRetrying): def __init__( self, sleep: t.Callable[ - [int | float], None | t.Awaitable[None] + [int | float], t.Awaitable[None] | None ] = _portable_async_sleep, stop: "StopBaseT" = tenacity.stop.stop_never, wait: "WaitBaseT" = tenacity.wait.wait_none(), retry: "SyncRetryBaseT | RetryBaseT" = tenacity.retry_if_exception_type(), before: t.Callable[ - ["RetryCallState"], None | t.Awaitable[None] + ["RetryCallState"], t.Awaitable[None] | None ] = before_nothing, - after: t.Callable[["RetryCallState"], None | t.Awaitable[None]] = after_nothing, - before_sleep: t.Callable[["RetryCallState"], None | t.Awaitable[None]] + after: t.Callable[["RetryCallState"], t.Awaitable[None] | None] = after_nothing, + before_sleep: t.Callable[["RetryCallState"], t.Awaitable[None] | None] | None = None, reraise: bool = False, retry_error_cls: type["RetryError"] = RetryError, diff --git a/tenacity/retry.py b/tenacity/retry.py index 7ef6a818..24180665 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -248,7 +248,7 @@ class retry_if_exception_message(retry_if_exception): def __init__( self, message: str | None = None, - match: None | str | re.Pattern[str] = None, + match: str | re.Pattern[str] | None = None, ) -> None: if message is not None and match is not None: raise TypeError( From d523344923c41f9b47211fe45e67100755f8a0d4 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Sat, 25 Jul 2026 18:47:38 +0530 Subject: [PATCH 4/5] style: ruff-format CancelledError test methods CI runs ruff format --check; wrap long async test def and normalize spacing around the new control-flow cases. --- tests/test_asyncio.py | 6 +++--- tests/test_tenacity.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 4e57f385..96d2bcfc 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -85,8 +85,6 @@ async def test_retry(self) -> None: assert thing.counter == thing.count @asynctest - - @asynctest async def test_wait_for_not_retried_with_retry_if_not_exception_type(self) -> None: """CancelledError must propagate under retry_if_not_exception_type (#529). @@ -118,7 +116,9 @@ async def sleepy() -> None: self.assertEqual(attempts, 1) @asynctest - async def test_cancelled_error_not_retried_even_if_listed_as_exception(self) -> None: + async def test_cancelled_error_not_retried_even_if_listed_as_exception( + self, + ) -> None: """Even if someone passes BaseException broadly, cancel still wins. ``retry_if_not_exception_type`` excludes control-flow exceptions diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index c6631494..a1a9bfed 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1390,7 +1390,6 @@ def test_retry_if_exception_message(self) -> None: print(_retryable_test_if_exception_message_message.statistics) self.fail("CustomError should've been retried from errormessage") - def test_retry_if_not_exception_type_skips_control_flow(self) -> None: """BaseException control-flow must not be retried (#529).""" calls = {"n": 0} From 3df03fcb9e47cc34cb76a3d0dd3b6c24285fa6c0 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Sat, 25 Jul 2026 18:50:34 +0530 Subject: [PATCH 5/5] test: drop duplicate @asynctest and restore iscoroutinefunction decorator Double @asynctest made asyncio.run receive None (wrapper result), failing wait_for coverage. Also restore @asynctest on test_iscoroutinefunction after insert. --- tests/test_asyncio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 96d2bcfc..10e7e072 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -84,7 +84,6 @@ async def test_retry(self) -> None: await _retryable_coroutine(thing) assert thing.counter == thing.count - @asynctest @asynctest async def test_wait_for_not_retried_with_retry_if_not_exception_type(self) -> None: """CancelledError must propagate under retry_if_not_exception_type (#529). @@ -141,6 +140,7 @@ async def sleepy() -> None: await sleepy() self.assertEqual(attempts, 1) + @asynctest async def test_iscoroutinefunction(self) -> None: assert asyncio.iscoroutinefunction(_retryable_coroutine) assert inspect.iscoroutinefunction(_retryable_coroutine)