From 0ff5e1cffe1f6803afa5cf9466ed7a3d1d5d90cf Mon Sep 17 00:00:00 2001 From: dchaudhari7177 <111210939+dchaudhari7177@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:44:54 +0530 Subject: [PATCH] Do not retry BaseExceptions that are not Exceptions retry_if_not_exception_type and retry_unless_exception_type both decide with `not isinstance(e, self.exception_types)`, so anything outside the listed types is retried -- including asyncio.CancelledError. That swallows a cancellation: under asyncio.wait_for the call is retried instead of unwinding, so wait_for does not even raise TimeoutError. KeyboardInterrupt, SystemExit and GeneratorExit are affected the same way. Skip exceptions that do not derive from Exception in both predicates. Those types derive from BaseException precisely so blanket handlers leave them alone, and retry_if_exception_type already skips them via its Exception default -- which stays the explicit opt-in for retrying one on purpose. Closes #529 --- tenacity/retry.py | 18 +++++++++++++++ tests/test_tenacity.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/tenacity/retry.py b/tenacity/retry.py index df0cc4d6..786c6586 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -114,6 +114,20 @@ def _check(self, e: BaseException) -> bool: return isinstance(e, self.exception_types) +def _signals_control_flow(e: BaseException) -> bool: + """Whether ``e`` interrupts execution rather than reporting a failure. + + ``asyncio.CancelledError``, ``KeyboardInterrupt``, ``SystemExit`` and + ``GeneratorExit`` derive from ``BaseException`` but not ``Exception``, + precisely so that blanket handlers leave them alone. Retrying one swallows + the cancellation, which breaks ``asyncio.wait_for`` and Ctrl-C. + + ``retry_if_exception_type`` defaults to ``Exception`` and so already skips + them; it stays the explicit opt-in for retrying one on purpose. + """ + return not isinstance(e, Exception) + + class retry_if_not_exception_type(retry_if_exception): """Retries except an exception has been raised of one or more types.""" @@ -126,6 +140,8 @@ def __init__( super().__init__(self._check) def _check(self, e: BaseException) -> bool: + if _signals_control_flow(e): + return False return not isinstance(e, self.exception_types) @@ -141,6 +157,8 @@ def __init__( super().__init__(self._check) def _check(self, e: BaseException) -> bool: + if _signals_control_flow(e): + return False return not isinstance(e, self.exception_types) def __call__(self, retry_state: "RetryCallState") -> bool: diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index b2c0289a..1f2eba65 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -13,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import contextlib import datetime import logging @@ -728,6 +729,56 @@ def r(fut: tenacity.Future) -> bool: self.assertTrue(r(tenacity.Future.construct(1, 2, False))) self.assertFalse(r(tenacity.Future.construct(1, 1, False))) + def _exception_retry_state(self, exc: BaseException) -> "tenacity.RetryCallState": + return make_retry_state( + 1, 1.0, last_result=tenacity.Future.construct(1, exc, True) + ) + + def test_retry_if_not_exception_type_skips_base_exceptions(self) -> None: + """A BaseException that is not an Exception is control flow, not a failure. + + Retrying asyncio.CancelledError swallows a cancellation, which breaks + asyncio.wait_for. + """ + retry = tenacity.retry_if_not_exception_type(IOError) + + for exc in ( + asyncio.CancelledError(), + KeyboardInterrupt(), + SystemExit(), + GeneratorExit(), + ): + self.assertFalse(retry(self._exception_retry_state(exc)), repr(exc)) + + def test_retry_unless_exception_type_skips_base_exceptions(self) -> None: + retry = tenacity.retry_unless_exception_type(NameError) + + for exc in ( + asyncio.CancelledError(), + KeyboardInterrupt(), + SystemExit(), + GeneratorExit(), + ): + self.assertFalse(retry(self._exception_retry_state(exc)), repr(exc)) + + def test_retry_if_not_exception_type_still_retries_exceptions(self) -> None: + retry = tenacity.retry_if_not_exception_type(IOError) + + self.assertTrue(retry(self._exception_retry_state(ValueError()))) + self.assertFalse(retry(self._exception_retry_state(OSError()))) + + def test_retry_unless_exception_type_still_retries_exceptions(self) -> None: + retry = tenacity.retry_unless_exception_type(NameError) + + self.assertTrue(retry(self._exception_retry_state(ValueError()))) + self.assertFalse(retry(self._exception_retry_state(NameError()))) + + def test_retry_if_exception_type_remains_the_opt_in(self) -> None: + """Retrying a cancellation on purpose is still possible, just explicit.""" + retry = tenacity.retry_if_exception_type(asyncio.CancelledError) + + self.assertTrue(retry(self._exception_retry_state(asyncio.CancelledError()))) + def test_retry_any(self) -> None: retry = tenacity.retry_any( tenacity.retry_if_result(lambda x: x == 1),