Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
5. If you added a feature, you must add tests for it.
6. If you fixed a bug, add a test that would have caught it.
7. **Verify README is up to date**: every symbol in `__all__` must be documented, every parameter table must reflect the actual signatures, and every claim must be backed by the code.
8. **Maintain 100% coverage**: run `coverage run -m pytest tests/ -q && coverage report --omit="tests/*"` — the `TOTAL` line must show `100%`. If coverage drops, add real tests before committing. Never use `# pragma: no cover` unless the branch is genuinely unreachable in the current Python version.

## Code Style

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 4.4.3 - 2026-07-24

- Remove dead code: 5 unreachable `if state.outcome is not None` guards in `_decide.py`, 2 `return True` dead-code statements in `_decorator.py` and `_helpers.py`, 1 always-true `if user_handlers is not None` guard in `_common.py`.
- Add `# pragma: no cover` to the `ParamSpec` stub in `_typing.py` (unreachable on Python 3.13+).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Inaccurate rationale. The stub is the < 3.10 fallback, so it's unreachable on 3.10+ (not just 3.13+) and still reachable on Python 3.9, which the project supports.

📝 Suggested wording
-- Add `# pragma: no cover` to the `ParamSpec` stub in `_typing.py` (unreachable on Python 3.13+).
+- Add `# pragma: no cover` to the `ParamSpec` stub in `_typing.py` (only executed on Python 3.9).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Add `# pragma: no cover` to the `ParamSpec` stub in `_typing.py` (unreachable on Python 3.13+).
- Add `# pragma: no cover` to the `ParamSpec` stub in `_typing.py` (only executed on Python 3.9).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 4, Update the CHANGELOG entry describing the ParamSpec
stub in _typing.py to state that it is the fallback for Python versions below
3.10, so it is unreachable on Python 3.10 and newer while remaining reachable on
supported Python 3.9.

- Achieve 100% branch coverage across all 20 source files by adding real tests for uncovered branches in `_decide.py`, `_loops.py`, `_inner.py`, `_classes.py`, `_common.py`, `_decorator.py`, `_fast.py`, `_helpers.py`, `_state.py`, `_conditions.py`, `_wait_gen.py`, and `_typing.py`.
- Remove duplicated tests from `test_coverage_gaps.py` (overlap with `test_edge_cases.py`).

## 4.4.2 - 2026-07-22

- Documentation: remove stale Metrics references from README. The metrics instrumentation (`MetricsCollector`, `PrometheusMetrics`, `OTelMetrics`, `StructlogMetrics`, `get_metrics_collector`, `set_metrics_collector`) was removed as dead code in 4.3.0, but the README still listed a `[Metrics](#metrics)` ToC entry (broken link), advertised "Prometheus / OpenTelemetry / structlog metrics" in the Features list, and included a "Metrics | Prometheus / OTel" row in the migration table. All three references are now gone.
Expand Down
9 changes: 4 additions & 5 deletions backon/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,10 @@ def _config_handlers(
handlers.append(
functools.partial(default_handler, logger=logger, log_level=log_level)
)
if user_handlers is not None:
if hasattr(user_handlers, "__iter__"):
handlers += list(user_handlers)
else:
handlers.append(user_handlers)
if hasattr(user_handlers, "__iter__"):
handlers += list(user_handlers)
else:
handlers.append(user_handlers)
elif user_handlers is not None:
if hasattr(user_handlers, "__iter__"):
handlers += list(user_handlers)
Expand Down
12 changes: 5 additions & 7 deletions backon/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,13 +515,11 @@ def decorate(target: Callable[P, R]) -> Callable[P, R]:
def _condition(state: RetryState) -> bool | float:
if not retry_if_exception_type(exc_types)(state):
return False
if state.outcome and isinstance(state.outcome.exception, Exception):
result = giveup(state.outcome.exception)
if isinstance(result, bool):
return not result
if isinstance(result, (int, float)):
return float(result)
return True
result = giveup(state.outcome.exception) # type: ignore[union-attr,arg-type]
if isinstance(result, bool):
return not result
if isinstance(result, (int, float)):
return float(result)
return True

condition = cast(RetryCondition, _condition)
Expand Down
15 changes: 5 additions & 10 deletions backon/_retry/_decide.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ def _decide_outcome(
seconds = _next_wait(wait, exc, jitter, state.elapsed, max_time)
except StopIteration:
return (_RetryAction.GIVEUP, None, details, False, True)
if state.outcome is not None:
state.outcome.wait = seconds
state.outcome.wait = seconds
if stop(state):
return (_RetryAction.GIVEUP, None, details, True, True)
call_state.upcoming_sleep = seconds
Expand All @@ -63,8 +62,7 @@ def _decide_outcome(
_condition_result = condition(state)
if _is_custom_wait(_condition_result):
seconds = float(_condition_result)
if state.outcome is not None:
state.outcome.wait = seconds
state.outcome.wait = seconds
if stop(state):
return (_RetryAction.GIVEUP, None, details, True, True)
call_state.upcoming_sleep = seconds
Expand All @@ -76,8 +74,7 @@ def _decide_outcome(
seconds = _next_wait(wait, exc, jitter, state.elapsed, max_time)
except StopIteration:
return (_RetryAction.GIVEUP, None, details, False, True)
if state.outcome is not None:
state.outcome.wait = seconds
state.outcome.wait = seconds
if stop(state):
return (_RetryAction.GIVEUP, None, details, True, True)
call_state.upcoming_sleep = seconds
Expand All @@ -92,8 +89,7 @@ def _decide_outcome(
_condition_result = condition(state)
if _is_custom_wait(_condition_result):
seconds = float(_condition_result)
if state.outcome is not None:
state.outcome.wait = seconds
state.outcome.wait = seconds
if stop(state):
return (_RetryAction.GIVEUP, None, details, True, False)
call_state.upcoming_sleep = seconds
Expand All @@ -105,8 +101,7 @@ def _decide_outcome(
seconds = _next_wait(wait, ret, jitter, state.elapsed, max_time)
except StopIteration:
return (_RetryAction.GIVEUP, None, details, True, False)
if state.outcome is not None:
state.outcome.wait = seconds
state.outcome.wait = seconds
if stop(state):
return (_RetryAction.GIVEUP, None, details, True, False)
call_state.upcoming_sleep = seconds
Expand Down
12 changes: 5 additions & 7 deletions backon/_retry/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,11 @@ def _make_default_condition(exception, giveup, predicate):
def wrapped(state):
if not retry_if_exception_type(exc_types)(state):
return False
if state.outcome and state.outcome.exception:
result = giveup(state.outcome.exception)
if isinstance(result, bool):
return not result
if isinstance(result, (int, float)):
return float(result)
return True
result = giveup(state.outcome.exception)
if isinstance(result, bool):
return not result
if isinstance(result, (int, float)):
return float(result)
return True

condition = wrapped
Expand Down
2 changes: 1 addition & 1 deletion backon/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import ParamSpec
else:

class ParamSpec: # type: ignore[no-redef]
class ParamSpec: # type: ignore[no-redef] # pragma: no cover
def __init__(self, name: str) -> None: ...


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "pdm.backend"

[project]
name = "backon"
version = "4.4.2"
version = "4.4.3"
description = "Function decoration for backoff and retry"
readme = "README.md"
license = "MIT"
Expand Down
45 changes: 45 additions & 0 deletions tests/test_backon_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,48 @@ def f():

with pytest.raises(ValueError):
f()


class TestOnPredicateWithStaticMethod:
def test_staticmethod_wrapped(self):
calls = []

class MyClass:
@backon.on_predicate(
backon.constant,
jitter=None,
interval=0.01,
max_tries=3,
sleep=lambda s: None,
logger=None,
)
@staticmethod
def flaky():
calls.append(1)
return # returns None (falsy) so retries

MyClass.flaky()
assert len(calls) == 3


class TestOnExceptionGiveupExplicitNone:
def test_giveup_none_skips_condition_closure(self):
calls = []

@backon.on_exception(
backon.constant,
ValueError,
max_tries=3,
jitter=None,
interval=0.01,
giveup=None,
sleep=lambda s: None,
logger=None,
)
def f():
calls.append(1)
raise ValueError("fail")

with pytest.raises(ValueError):
f()
assert len(calls) == 3
159 changes: 159 additions & 0 deletions tests/test_coverage_gaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,29 @@ async def target():
backon.enable()


class TestRetryApiAsyncExplicitConditionStop:
@pytest.mark.asyncio
async def test_retry_async_with_explicit_condition_stop(self):
from backon._retry._api import _retry_async

calls = []

async def target():
calls.append(1)
raise ValueError("fail")

with pytest.raises(ValueError):
await _retry_async(
target,
backon.constant,
condition=backon.retry_if_exception_type(ValueError),
stop=backon.stop_after_attempt(2),
jitter=None,
logger=None,
)
assert len(calls) == 2


class TestRetryLoopsRateLimit:
def test_rate_limit_sync(self):
calls = []
Expand Down Expand Up @@ -549,6 +572,142 @@ async def target():
assert result is None


class TestLoopTryAgainEdgeCases:
def test_sync_loop_try_again_stop_iteration(self):
from backon._wait_gen import _Wait

class _FiniteWait(_Wait):
def __init__(self, **kw):
self._calls = 0

def next(self, send=None):
self._calls += 1
if self._calls >= 2:
raise StopIteration
return 0.01

calls = []

def target():
calls.append(1)
raise backon.TryAgain

result = backon.retry(
target,
_FiniteWait,
condition=backon.retry_always(),
max_tries=None,
jitter=None,
sleep=lambda s: None,
logger=None,
on_backoff=lambda d: None,
raise_on_giveup=False,
)
assert result is None
assert len(calls) == 2

@pytest.mark.asyncio
async def test_async_loop_try_again_stop_iteration(self):
from backon._wait_gen import _Wait

class _FiniteWait(_Wait):
def __init__(self, **kw):
self._calls = 0

def next(self, send=None):
self._calls += 1
if self._calls >= 2:
raise StopIteration
return 0.01

calls = []

async def target():
calls.append(1)
raise backon.TryAgain

result = await backon.retry(
target,
_FiniteWait,
condition=backon.retry_always(),
max_tries=None,
jitter=None,
logger=None,
on_backoff=lambda d: None,
raise_on_giveup=False,
)
assert result is None
assert len(calls) == 2

@pytest.mark.asyncio
async def test_async_loop_try_again_stop_true(self):
calls = []

async def target():
calls.append(1)
if len(calls) < 3:
raise backon.TryAgain
return "ok"

result = await backon.retry(
target,
backon.constant,
condition=backon.retry_always(),
max_tries=2,
jitter=None,
interval=0.01,
logger=None,
on_backoff=lambda d: None,
)
assert result is None
assert len(calls) == 2

def test_sync_loop_try_again_positive_wait(self):
calls = []

def target():
calls.append(1)
if len(calls) < 2:
raise backon.TryAgain
return "ok"

result = backon.retry(
target,
backon.constant,
condition=backon.retry_always(),
max_tries=3,
jitter=None,
interval=0.01,
sleep=lambda s: None,
logger=None,
on_backoff=lambda d: None,
)
assert result == "ok"

def test_sync_loop_try_again_zero_wait(self):
from backon._wait_gen import wait_none

calls = []

def target():
calls.append(1)
if len(calls) < 2:
raise backon.TryAgain
return "ok"

result = backon.retry(
target,
wait_none,
condition=backon.retry_always(),
max_tries=3,
jitter=None,
sleep=lambda s: None,
logger=None,
on_backoff=lambda d: None,
)
assert result == "ok"


class TestWaitGenEdgeCases:
def test_combined_wait_add_combined_wait(self):
w1 = _Wait()
Expand Down
Loading
Loading