Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
task: mypy
steps:
- name: Checkout 🛎️
uses: actions/checkout@v7.0.0
uses: actions/checkout@v6.0.3

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated

with:
fetch-depth: 0

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v6.0.3
with:
fetch-depth: 0
fetch-tags: true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
fixes:
- |
``retry_base`` operators (``|``, ``&``, and their reflected forms)
now validate their operand at the operator site. A non-callable,
non-``retry_base`` value (e.g. ``True | retry_always``, ``retry & 42``,
``"nope" | retry``, ``None & retry``) used to silently construct a
``retry_any``/``retry_all`` whose ``__call__`` later raised
``TypeError: '<type>' object is not callable`` from inside the retry
loop, far from the original misuse. The operators now raise a clear
``TypeError`` naming the bad operand at the construction site, so
common typos like ``retry or True`` fail fast with an actionable
message.
20 changes: 20 additions & 0 deletions tenacity/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,24 @@ class retry_base(abc.ABC):
def __call__(self, retry_state: "RetryCallState") -> bool:
pass

@staticmethod
def _validate_predicate(other: "RetryBaseT") -> None:
# The RetryBaseT union documents that | / & accept either a
# retry_base subclass or a plain callable. Without this guard a
# non-callable (e.g. ``True | retry_always``) builds a
# retry_any/retry_all whose ``__call__`` later raises
# ``TypeError: 'bool' object is not callable`` far from the
# original misuse. Rejecting the value at the operator site
# gives a clear error pointing at the bad operand.
if isinstance(other, retry_base) or callable(other):
return
raise TypeError(
"Retry predicates must be retry_base instances or callables, "
f"got {type(other).__name__}: {other!r}"
)

def __and__(self, other: "RetryBaseT") -> "retry_all":
self._validate_predicate(other)
if isinstance(other, retry_base):
return other.__rand__(self)
# Plain callable: flatten if self is already a retry_all
Expand All @@ -38,12 +55,14 @@ def __and__(self, other: "RetryBaseT") -> "retry_all":
return retry_all(self, other)

def __rand__(self, other: "RetryBaseT") -> "retry_all":
self._validate_predicate(other)
# Flatten if other is already a retry_all
if isinstance(other, retry_all):
return retry_all(*other.retries, self)
return retry_all(other, self)

def __or__(self, other: "RetryBaseT") -> "retry_any":
self._validate_predicate(other)
if isinstance(other, retry_base):
return other.__ror__(self)
# Plain callable: flatten if self is already a retry_any
Expand All @@ -52,6 +71,7 @@ def __or__(self, other: "RetryBaseT") -> "retry_any":
return retry_any(self, other)

def __ror__(self, other: "RetryBaseT") -> "retry_any":
self._validate_predicate(other)
# Flatten if other is already a retry_any
if isinstance(other, retry_any):
return retry_any(*other.retries, self)
Expand Down
29 changes: 29 additions & 0 deletions tests/test_tenacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,35 @@ def test_retry_and_coalesces(self) -> None:
self.assertIsInstance(combined, retry_all)
self.assertEqual(len(combined.retries), 3)

def test_retry_composition_rejects_non_callable(self) -> None:
"""Non-callable, non-retry_base operands (bool/int/None/str/...)
used to silently construct a retry_any/retry_all that raised
``TypeError: '<type>' object is not callable`` from inside the
retry loop. The operator should now raise a clear TypeError at
the construction site that names the bad operand."""
retry = tenacity.retry_always

# `True | retry` exercises __ror__ on retry_base
with self.assertRaises(TypeError) as ctx:
True | retry # noqa: B018
self.assertIn("callable", str(ctx.exception))

# `retry | False` exercises __or__ on retry_base
with self.assertRaises(TypeError):
retry | False # noqa: B018

# An int is a non-callable, non-retry_base
with self.assertRaises(TypeError):
retry & 42 # noqa: B018

# A string is a non-callable, non-retry_base
with self.assertRaises(TypeError):
"nope" | retry # noqa: B018

# The same guard must apply on the right side of & via __rand__
with self.assertRaises(TypeError):
None & retry # noqa: B018

def _raise_try_again(self) -> None:
self._attempts += 1
if self._attempts < 3:
Expand Down
Loading