From d9da50047ea9df4a62da614f84cf033f46f31dd1 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Thu, 25 Jun 2026 01:02:25 +0200 Subject: [PATCH] fix: respect enabled=False in iterator protocols (__iter__ / __aiter__) When Retrying(enabled=False) was used with the context-manager iterator pattern (`for attempt in retrying` / `async for attempt in retrying`), the `enabled` flag was silently ignored and the full retry machinery ran (multiple attempts, stop/wait evaluated) instead of executing the body exactly once and propagating any exception directly. The `wraps()`-based path already handled `enabled=False` correctly, so this patch brings the iterator API in line with it: - BaseRetrying.__iter__: if not enabled, yield one AttemptManager and re-raise the stored exception if the attempt failed. - AsyncRetrying.__aiter__ / __anext__: same semantics via a one-shot flag that stops after the first yielded AttemptManager. Four regression tests are added (two sync, two async) covering the failure and success cases for each protocol. --- tenacity/__init__.py | 7 +++++++ tenacity/asyncio/__init__.py | 14 ++++++++++++++ tests/test_asyncio.py | 29 +++++++++++++++++++++++++++++ tests/test_tenacity.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index c52d6ba0..e9532fc2 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -475,6 +475,13 @@ def next_action(rs: "RetryCallState") -> None: self._add_action_func(lambda rs: DoSleep(rs.upcoming_sleep)) def __iter__(self) -> t.Generator[AttemptManager, None, None]: + if not self.enabled: + retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) + yield AttemptManager(retry_state=retry_state) + if retry_state.outcome is not None and retry_state.outcome.failed: + raise retry_state.outcome.exception() # type: ignore[misc] + return + self.begin() retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 214a7ae9..64598949 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -166,11 +166,25 @@ def __iter__(self) -> t.Generator[AttemptManager, None, None]: raise TypeError("AsyncRetrying object is not iterable") def __aiter__(self) -> "AsyncRetrying": + if not self.enabled: + self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) + self._disabled_iter_done = False + return self + self.begin() self._retry_state = RetryCallState(self, fn=None, args=(), kwargs={}) return self async def __anext__(self) -> AttemptManager: + if not self.enabled: + if self._disabled_iter_done: + outcome = self._retry_state.outcome + if outcome is not None and outcome.failed: + raise outcome.exception() # type: ignore[misc] + raise StopAsyncIteration + self._disabled_iter_done = True + return AttemptManager(retry_state=self._retry_state) + while True: do = await self.iter(retry_state=self._retry_state) if do is None: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index c6d27865..20b320e1 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -176,6 +176,35 @@ async def always_fails() -> None: await always_fails() assert call_count == 1 + @asynctest + async def test_enabled_false_aiter_raises_original_exception(self) -> None: + """When enabled=False, the async iterator raises the original exception, + not a RetryError, and the body executes exactly once.""" + call_count = 0 + retrying = AsyncRetrying( + enabled=False, + stop=stop_after_attempt(5), + ) + with pytest.raises(ValueError, match="fail"): + async for attempt in retrying: + with attempt: + call_count += 1 + raise ValueError("fail") + assert call_count == 1 + + @asynctest + async def test_enabled_false_aiter_succeeds_on_first_attempt(self) -> None: + """When enabled=False, the async iterator runs the body once and stops.""" + call_count = 0 + retrying = AsyncRetrying( + enabled=False, + stop=stop_after_attempt(5), + ) + async for attempt in retrying: + with attempt: + call_count += 1 + assert call_count == 1 + @unittest.skipIf(not have_trio, "trio not installed") class TestTrio(unittest.TestCase): diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 31e7f432..9ecdea56 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -1561,6 +1561,35 @@ def fails_twice() -> bool: assert fails_twice() is True assert call_count == 3 + def test_enabled_false_iter_raises_original_exception(self) -> None: + """When enabled=False, the iterator protocol raises the original exception, + not a RetryError, and the body executes exactly once.""" + call_count = 0 + retrying = Retrying( + enabled=False, + stop=tenacity.stop_after_attempt(5), + wait=tenacity.wait_none(), + ) + with pytest.raises(ValueError, match="fail"): + for attempt in retrying: + with attempt: + call_count += 1 + raise ValueError("fail") + assert call_count == 1 + + def test_enabled_false_iter_succeeds_on_first_attempt(self) -> None: + """When enabled=False, the iterator protocol runs the body once and stops.""" + call_count = 0 + retrying = Retrying( + enabled=False, + stop=tenacity.stop_after_attempt(5), + wait=tenacity.wait_none(), + ) + for attempt in retrying: + with attempt: + call_count += 1 + assert call_count == 1 + class TestRetryWith: def test_redefine_wait(self) -> None: