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 e59f4c0a..24180665 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: @@ -221,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( diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 96560b07..10e7e072 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, ) @@ -83,6 +84,62 @@ async def test_retry(self) -> None: await _retryable_coroutine(thing) assert thing.counter == thing.count + @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) + @asynctest async def test_iscoroutinefunction(self) -> None: assert asyncio.iscoroutinefunction(_retryable_coroutine) diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index ba44b0c8..a1a9bfed 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1390,6 +1390,24 @@ 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(