Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions tenacity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,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."""
Expand Down Expand Up @@ -701,9 +701,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]]
Expand All @@ -718,9 +718,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,
Expand Down
8 changes: 4 additions & 4 deletions tenacity/asyncio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 29 additions & 2 deletions tenacity/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,31 @@ def _check(self, e: BaseException) -> bool:
return isinstance(e, self.exception_types)


def _is_control_flow_exception(e: BaseException) -> bool:
"""Return True for exceptions that must never trigger a retry.

Task cancellation (``asyncio.CancelledError``), keyboard interrupt, and
interpreter exit are control-flow signals — not transient failures.
``CancelledError`` is a bare ``BaseException`` on Python 3.9+; treating it
like a normal error breaks ``asyncio.wait_for`` / ``asyncio.timeout``
(see #529).
"""
if isinstance(e, (KeyboardInterrupt, SystemExit, GeneratorExit)):
return True
try:
import asyncio
except ImportError: # pragma: no cover
return False
return isinstance(e, asyncio.CancelledError)


class retry_if_not_exception_type(retry_if_exception):
"""Retries except an exception has been raised of one or more types."""
"""Retries except an exception has been raised of one or more types.

Control-flow exceptions (``CancelledError``, ``KeyboardInterrupt``,
``SystemExit``, ``GeneratorExit``) are never retried, even when they are
not listed in ``exception_types``.
"""

def __init__(
self,
Expand All @@ -126,6 +149,8 @@ def __init__(
super().__init__(self._check)

def _check(self, e: BaseException) -> bool:
if _is_control_flow_exception(e):
return False
return not isinstance(e, self.exception_types)


Expand All @@ -141,6 +166,8 @@ def __init__(
super().__init__(self._check)

def _check(self, e: BaseException) -> bool:
if _is_control_flow_exception(e):
return False
return not isinstance(e, self.exception_types)

def __call__(self, retry_state: "RetryCallState") -> bool:
Expand Down Expand Up @@ -221,7 +248,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(
Expand Down
57 changes: 57 additions & 0 deletions tests/test_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
RetryError,
retry,
retry_if_exception,
retry_if_not_exception_type,
retry_if_result,
stop_after_attempt,
)
Expand Down Expand Up @@ -83,6 +84,62 @@ async def test_retry(self) -> None:
await _retryable_coroutine(thing)
assert thing.counter == thing.count

@asynctest
async def test_wait_for_not_retried_with_retry_if_not_exception_type(self) -> None:
"""CancelledError must propagate under retry_if_not_exception_type (#529).

Default retry only retries ``Exception`` subclasses, so cancellation
already works. The bug is specific to ``retry_if_not_exception_type``,
which treated *any* non-listed exception — including CancelledError —
as retryable, breaking ``asyncio.wait_for``.
"""
attempts = 0

@retry(
wait=wait_fixed(0.01),
stop=stop_after_attempt(5),
reraise=True,
retry=retry_if_not_exception_type(ValueError),
)
async def sleepy() -> None:
nonlocal attempts
attempts += 1
await asyncio.sleep(10)

# On 3.11+ wait_for raises TimeoutError after cancelling the task.
# The critical assertion is attempts==1 (no retry of cancellation).
with self.assertRaises((asyncio.TimeoutError, asyncio.CancelledError)):
await asyncio.wait_for(sleepy(), timeout=0.05)

# One attempt only — cancellation must not be retried.
self.assertEqual(attempts, 1)

@asynctest
async def test_cancelled_error_not_retried_even_if_listed_as_exception(
self,
) -> None:
"""Even if someone passes BaseException broadly, cancel still wins.

``retry_if_not_exception_type`` excludes control-flow exceptions
before applying the user type filter.
"""
attempts = 0

@retry(
wait=wait_fixed(0.01),
stop=stop_after_attempt(5),
reraise=True,
retry=retry_if_not_exception_type(RuntimeError),
)
async def sleepy() -> None:
nonlocal attempts
attempts += 1
raise asyncio.CancelledError

with self.assertRaises(asyncio.CancelledError):
await sleepy()
self.assertEqual(attempts, 1)

@asynctest
async def test_iscoroutinefunction(self) -> None:
assert asyncio.iscoroutinefunction(_retryable_coroutine)
Expand Down
18 changes: 18 additions & 0 deletions tests/test_tenacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,24 @@ def test_retry_if_exception_message(self) -> None:
print(_retryable_test_if_exception_message_message.statistics)
self.fail("CustomError should've been retried from errormessage")

def test_retry_if_not_exception_type_skips_control_flow(self) -> None:
"""BaseException control-flow must not be retried (#529)."""
calls = {"n": 0}

def boom() -> None:
calls["n"] += 1
raise KeyboardInterrupt

r = tenacity.Retrying(
wait=tenacity.wait_fixed(0),
stop=tenacity.stop_after_attempt(3),
reraise=True,
retry=tenacity.retry_if_not_exception_type(ValueError),
)
with self.assertRaises(KeyboardInterrupt):
r(boom)
self.assertEqual(calls["n"], 1)

def test_retry_if_not_exception_message(self) -> None:
try:
self.assertTrue(
Expand Down