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),