diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 6b591464..61a0ad5a 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -54,6 +54,7 @@ retry_if_not_result, retry_if_result, retry_never, + retry_unless_exception_cause_type, retry_unless_exception_type, ) @@ -272,13 +273,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 +702,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 +719,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, @@ -808,6 +809,7 @@ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]: "retry_if_not_result", "retry_if_result", "retry_never", + "retry_unless_exception_cause_type", "retry_unless_exception_type", "sleep", "sleep_using_event", 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..21cefd3b 100644 --- a/tenacity/retry.py +++ b/tenacity/retry.py @@ -157,11 +157,34 @@ def __call__(self, retry_state: "RetryCallState") -> bool: return self.predicate(exception) +def _cause_chain_contains( + exc: BaseException | None, + exception_types: type[BaseException] | tuple[type[BaseException], ...], +) -> bool: + """True if any ``__cause__`` in the chain is an instance of *exception_types*. + + Cycle-safe: a self-referential ``raise e from e`` cannot hang the retry + loop (issue #658). + """ + seen: set[int] = set() + while exc is not None: + cause = exc.__cause__ + if cause is None: + return False + if id(cause) in seen: + return False + if isinstance(cause, exception_types): + return True + seen.add(id(cause)) + exc = cause + return False + + class retry_if_exception_cause_type(retry_base): """Retries if any of the causes of the raised exception is of one or more types. - The check on the type of the cause of the exception is done recursively (until finding - an exception in the chain that has no `__cause__`) + The check on the type of the cause of the exception is done recursively + (cycle-safe until the chain ends or loops). """ def __init__( @@ -176,15 +199,40 @@ def __call__(self, retry_state: "RetryCallState") -> bool: raise RuntimeError("__call__ called before outcome was set") if retry_state.outcome.failed: - exc = retry_state.outcome.exception() - while exc is not None: - if isinstance(exc.__cause__, self.exception_cause_types): - return True - exc = exc.__cause__ - + return _cause_chain_contains( + retry_state.outcome.exception(), self.exception_cause_types + ) return False +class retry_unless_exception_cause_type(retry_base): + """Retries until a cause of the raised exception is of one or more types. + + Mirror of :class:`retry_if_exception_cause_type`: keep retrying while the + ``__cause__`` chain does *not* contain a matching type. Successful outcomes + always retry (same convention as :class:`retry_unless_exception_type`). + """ + + def __init__( + self, + exception_types: type[BaseException] + | tuple[type[BaseException], ...] = Exception, + ) -> None: + self.exception_cause_types = exception_types + + def __call__(self, retry_state: "RetryCallState") -> bool: + if retry_state.outcome is None: + raise RuntimeError("__call__ called before outcome was set") + + # always retry if no exception was raised + if not retry_state.outcome.failed: + return True + + return not _cause_chain_contains( + retry_state.outcome.exception(), self.exception_cause_types + ) + + class retry_if_result(retry_base): """Retries if the result verifies a predicate.""" @@ -221,7 +269,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_tenacity.py b/tests/test_tenacity.py index ba44b0c8..011aff08 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1172,6 +1172,15 @@ def _retryable_test_with_exception_cause_type(thing: typing.Any) -> typing.Any: return thing.go() +@retry( + stop=tenacity.stop_after_attempt(5), + retry=tenacity.retry_unless_exception_cause_type(NameError), + reraise=True, +) +def _retryable_test_unless_exception_cause_type(thing: typing.Any) -> typing.Any: + return thing.go() + + @retry(retry=tenacity.retry_if_exception_type(IOError)) def _retryable_test_with_exception_type_io(thing: typing.Any) -> typing.Any: return thing.go() @@ -1441,6 +1450,59 @@ def test_retry_if_exception_cause_type(self) -> None: except NameError: pass + def test_retry_unless_exception_cause_type(self) -> None: + # Cause is NameError → unless NameError-cause stops (no retry). + with self.assertRaises(OSError): + _retryable_test_unless_exception_cause_type(NoNameErrorCauseAfterCount(5)) + self.assertEqual( + _retryable_test_unless_exception_cause_type.statistics["attempt_number"], + 1, + ) + + # Cause is OSError, not NameError → predicate says retry. + from tenacity import Future + from tenacity.retry import retry_unless_exception_cause_type + + pred = retry_unless_exception_cause_type(NameError) + # Build a fake failed state: NameError from OSError + try: + raise OSError("root") + except OSError as root: + try: + raise NameError("wrap") from root + except NameError as wrap: + fut = Future(1) + fut.set_exception(wrap) + + class RS: + outcome = fut + + self.assertTrue(pred(RS())) # type: ignore[arg-type] + + # Cause is NameError → do not retry + try: + raise NameError("root") + except NameError as root: + try: + raise OSError("wrap") from root + except OSError as wrap: + fut2 = Future(1) + fut2.set_exception(wrap) + + class RS2: + outcome = fut2 + + self.assertFalse(pred(RS2())) # type: ignore[arg-type] + + def test_cause_chain_cycle_does_not_hang(self) -> None: + """Cyclic __cause__ must not spin forever (#658).""" + from tenacity.retry import _cause_chain_contains + + e = RuntimeError("loop") + type(e).__setattr__(e, "__cause__", e) + self.assertFalse(_cause_chain_contains(e, ValueError)) + self.assertTrue(_cause_chain_contains(e, RuntimeError)) + def test_retry_preserves_argument_defaults(self) -> None: def function_with_defaults(a: int = 1) -> int: return a