diff --git a/releasenotes/notes/fix-reraise-tryagain-underlying-exc-e21a26ee95bad0b3.yaml b/releasenotes/notes/fix-reraise-tryagain-underlying-exc-e21a26ee95bad0b3.yaml new file mode 100644 index 00000000..ac8f239a --- /dev/null +++ b/releasenotes/notes/fix-reraise-tryagain-underlying-exc-e21a26ee95bad0b3.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + When ``reraise=True`` and ``TryAgain`` is raised from within an ``except`` + block, the underlying exception that triggered the retry is now reraised + once attempts are exhausted, instead of the opaque ``TryAgain`` sentinel. + A bare ``TryAgain`` raised without an active exception keeps reraising + ``TryAgain`` as before. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 282e6dae..c52d6ba0 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -185,6 +185,14 @@ def __init__(self, last_attempt: "Future") -> None: def reraise(self) -> t.NoReturn: if self.last_attempt.failed: + exc = self.last_attempt.exception() + # When the user explicitly raises TryAgain (typically from within + # an "except" block), surface the underlying exception that caused + # the retry rather than the opaque TryAgain sentinel. + if isinstance(exc, TryAgain): + cause = exc.__cause__ or exc.__context__ + if cause is not None: + raise cause.with_traceback(cause.__traceback__) from None raise self.last_attempt.result() raise self diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 7b5c6416..c6d27865 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -238,6 +238,29 @@ class CustomError(Exception): else: raise Exception + @asynctest + async def test_reraise_try_again_with_cause(self) -> None: + # When TryAgain is raised from within an "except" block, reraise=True + # should surface the underlying exception rather than TryAgain itself. + class UnderlyingError(Exception): + pass + + async def _test() -> None: + try: + raise UnderlyingError("boom") + except UnderlyingError: + # Implicit chaining via __context__ is exactly what we test. + raise tenacity.TryAgain # noqa: B904 + + retrying = tasyncio.AsyncRetrying( + stop=stop_after_attempt(2), + retry=tenacity.retry_never, + reraise=True, + ) + with pytest.raises(UnderlyingError): + await retrying(_test) + self.assertEqual(2, retrying.statistics["attempt_number"]) + @asynctest async def test_sleeps(self) -> None: start = current_time_ms() diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 6a397392..31e7f432 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -857,6 +857,43 @@ def _r() -> None: self.assertRaises(tenacity.RetryError, r, _r) self.assertEqual(5, r.statistics["attempt_number"]) + def test_retry_try_again_with_cause_reraise(self) -> None: + # When TryAgain is raised from within an "except" block, reraise=True + # should surface the underlying exception rather than TryAgain itself. + class UnderlyingError(Exception): + pass + + def _r() -> None: + try: + raise UnderlyingError("boom") + except UnderlyingError: + # Implicit chaining via __context__ is exactly what we test. + raise tenacity.TryAgain # noqa: B904 + + r = Retrying( + stop=tenacity.stop_after_attempt(5), + retry=tenacity.retry_never, + reraise=True, + ) + self.assertRaises(UnderlyingError, r, _r) + self.assertEqual(5, r.statistics["attempt_number"]) + + def test_retry_try_again_from_cause_reraise(self) -> None: + # An explicit "raise TryAgain from exc" should also be unwrapped. + class UnderlyingError(Exception): + pass + + def _r() -> None: + raise tenacity.TryAgain from UnderlyingError("boom") + + r = Retrying( + stop=tenacity.stop_after_attempt(5), + retry=tenacity.retry_never, + reraise=True, + ) + self.assertRaises(UnderlyingError, r, _r) + self.assertEqual(5, r.statistics["attempt_number"]) + def test_retry_try_again_forever_reraise(self) -> None: def _r() -> None: raise tenacity.TryAgain