Skip to content

Release v4.4.3 - #69

Merged
Llucs merged 1 commit into
mainfrom
release-v4.4.3
Jul 24, 2026
Merged

Release v4.4.3#69
Llucs merged 1 commit into
mainfrom
release-v4.4.3

Conversation

@Llucs

@Llucs Llucs commented Jul 24, 2026

Copy link
Copy Markdown
Owner

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+).
  • Achieve 100% branch coverage across all 20 source files.
  • Remove duplicated tests from test_coverage_gaps.py.

Summary by CodeRabbit

  • New Features

    • Released version 4.4.3.
    • Improved retry handling for static methods, asynchronous operations, custom conditions, and give-up callbacks.
    • Enhanced retry scheduling across exception- and result-based retry scenarios.
  • Bug Fixes

    • Improved handling of retry-loop edge cases, including zero waits, exhausted attempts, and TryAgain flows.
    • Corrected behavior for custom wait strategies and stop conditions.
  • Documentation

    • Added changelog details and strengthened test-coverage requirements.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Retry callback handling and wait-state updates were simplified, package metadata was advanced to 4.4.3, and broad synchronous, asynchronous, edge-case, and fast-path tests were added to enforce 100% coverage.

Changes

Retry behavior and coverage

Layer / File(s) Summary
Retry condition and wait handling
backon/_common.py, backon/_decorator.py, backon/_retry/..., backon/_typing.py, pyproject.toml, CHANGELOG.md
Retry callback handling and wait metadata assignments were simplified, the typing fallback received a coverage pragma, and release metadata was updated.
Retry edge-case coverage
AGENTS.md, tests/test_backon_sync.py, tests/test_coverage_gaps.py, tests/test_edge_cases.py
Tests cover handler setup, retry conditions, wait generators, iterator behavior, stop decisions, inner retry helpers, TryAgain flows, and synchronous decorator cases.
Fast-path and slow-path coverage
tests/test_fast_path.py
Tests cover fast-path selection, TryAgain handling, give-up behavior, direct fast-path helpers, and synchronous and asynchronous slow-path transitions.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the version bump and release-focused changes in this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release-v4.4.3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Llucs
Llucs merged commit 8a43dca into main Jul 24, 2026
13 of 14 checks passed
@Llucs
Llucs deleted the release-v4.4.3 branch July 24, 2026 23:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (7)
tests/test_coverage_gaps.py (1)

665-708: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

These two cases duplicate tests/test_edge_cases.py.

test_sync_loop_try_again_positive_wait / test_sync_loop_try_again_zero_wait mirror TestAsyncLoopTryAgain (test_edge_cases.py Lines 1419-1461) and TestFastPathTryAgain (test_fast_path.py Lines 477-519) sync/async pairs. Since this PR explicitly removes overlap from test_coverage_gaps.py, consider keeping the TryAgain cases in one file.

🤖 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 `@tests/test_coverage_gaps.py` around lines 665 - 708, Remove the duplicate
test_sync_loop_try_again_positive_wait and test_sync_loop_try_again_zero_wait
cases from test_coverage_gaps.py. Keep the existing TryAgain coverage in the
established test_edge_cases.py and test_fast_path.py suites unchanged.
tests/test_edge_cases.py (1)

950-953: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the no-op contract. reraise() is expected to return silently when the attempt holds no exception; make that explicit so a future behavior change (e.g. raising RetryError) fails the test.

assert err.reraise() is None
🤖 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 `@tests/test_edge_cases.py` around lines 950 - 953, Update
test_retry_error_reraise_no_exception to explicitly assert that err.reraise()
returns None when Attempt has no exception, preserving the no-op contract and
failing if it starts raising or returning another value.
pyproject.toml (1)

74-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider raising fail_under to match the new 100% policy.

AGENTS.md step 8 now mandates a 100% TOTAL, but the automated gate is still 95, so a regression won't fail CI.

♻️ Suggested change
 [tool.coverage.report]
-fail_under = 95
+fail_under = 100
 show_missing = true
🤖 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 `@pyproject.toml` around lines 74 - 77, Update the [tool.coverage.report]
fail_under threshold from 95 to 100 so the automated coverage gate enforces the
100% TOTAL policy, while preserving the existing show_missing and skip_empty
settings.
tests/test_backon_sync.py (1)

254-263: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Widen giveup annotations where None is supported.

_make_retry_with already accepts _Predicate[Exception] | None, but Decider.on_exception and other public on_exception signatures still default to a concrete predicate and reject None in the type, even though the implementation and tests rely on giveup=None. Add | None to those signatures so the supported behavior is type-checked.

🤖 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 `@tests/test_backon_sync.py` around lines 254 - 263, Update
Decider.on_exception and every public on_exception signature to annotate giveup
as _Predicate[Exception] | None, matching _make_retry_with and the existing
giveup=None behavior. Preserve the current default and runtime handling while
widening only the type annotations.
tests/test_fast_path.py (3)

963-981: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use an awaitable no-op sleep in async tests.

sleep=lambda s: None is only safe here because the computed wait is exactly 0.0 and never awaited; any change in wait computation turns these into TypeError instead of a meaningful assertion failure. Reuse the _noop coroutine already defined at 928-929.

Also applies to: 999-1030

🤖 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 `@tests/test_fast_path.py` around lines 963 - 981, Replace the synchronous
sleep lambda in test_try_again_stop_fires_async and the async tests at the
referenced later range with the existing _noop awaitable coroutine. Keep the
retry parameters and assertions unchanged.

563-682: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated _OneShotWait helper.

The same one-shot wait stub is redefined four times in this class (563-591, 593-621, 623-651, 653-681) and three more times in TestFastPathMore (836, 927, 999). Hoist a single module-level helper (parametrizing the returned interval) and use it everywhere.

♻️ Sketch
class _OneShotWait:
    def __init__(self, interval=0.0):
        self._interval = interval
        self._called = False

    def __call__(self, **kw):
        return _OneShotWait(self._interval)

    def next(self, send=None):
        if self._called:
            raise StopIteration
        self._called = True
        return self._interval
🤖 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 `@tests/test_fast_path.py` around lines 563 - 682, Extract the repeated
_OneShotWait definitions into one module-level helper, preserving the current
one-shot StopIteration behavior and allowing the returned interval to be
configured via an interval parameter. Replace all four local definitions in the
shown retry tests and the three corresponding definitions in TestFastPathMore
with the shared helper, passing any required interval values.

353-451: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assertions don't verify the slow path was taken.

These tests only assert the exception propagates, which also holds on the fast path, so a regression in _is_fast_path routing wouldn't fail them. calls in test_on_success_forces_slow_path (391-396) and test_on_giveup_forces_slow_path is collected but never asserted. Consider asserting _is_fast_path(...) is False for the given config, or asserting exact handler invocation counts (e.g. on_giveup fires once).

🤖 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 `@tests/test_fast_path.py` around lines 353 - 451, Strengthen the slow-path
tests in test_rate_limit_alone_forces_slow_path,
test_on_success_forces_slow_path, test_on_backoff_forces_slow_path, and
test_on_giveup_forces_slow_path so they verify slow-path-specific behavior
rather than only exception propagation. Assert the relevant callback invocation
counts, including the collected calls in the success and give-up tests, or
directly validate _is_fast_path(...) is False for each configuration.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@CHANGELOG.md`:
- 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.

In `@tests/test_edge_cases.py`:
- Around line 1077-1099: Update test_on_exception_async_gen_disabled so
backon.disable() is paired with a try/finally block, placing the existing
generator iteration and assertions in the protected section and restoring global
state with backon.enable() in finally. Apply the same cleanup structure to the
related test covering lines 1101-1121, preserving its existing assertions and
behavior.

In `@tests/test_fast_path.py`:
- Around line 753-772: Update test_fast_inner_wait_gen_kwargs_none_async and any
other async _retry_fast_async_inner tests lacking a sleep override to inject a
no-op or very small async sleep while retaining jitter=None, so retries avoid
the wait generator’s default interval and each test stays under 100ms.

---

Nitpick comments:
In `@pyproject.toml`:
- Around line 74-77: Update the [tool.coverage.report] fail_under threshold from
95 to 100 so the automated coverage gate enforces the 100% TOTAL policy, while
preserving the existing show_missing and skip_empty settings.

In `@tests/test_backon_sync.py`:
- Around line 254-263: Update Decider.on_exception and every public on_exception
signature to annotate giveup as _Predicate[Exception] | None, matching
_make_retry_with and the existing giveup=None behavior. Preserve the current
default and runtime handling while widening only the type annotations.

In `@tests/test_coverage_gaps.py`:
- Around line 665-708: Remove the duplicate
test_sync_loop_try_again_positive_wait and test_sync_loop_try_again_zero_wait
cases from test_coverage_gaps.py. Keep the existing TryAgain coverage in the
established test_edge_cases.py and test_fast_path.py suites unchanged.

In `@tests/test_edge_cases.py`:
- Around line 950-953: Update test_retry_error_reraise_no_exception to
explicitly assert that err.reraise() returns None when Attempt has no exception,
preserving the no-op contract and failing if it starts raising or returning
another value.

In `@tests/test_fast_path.py`:
- Around line 963-981: Replace the synchronous sleep lambda in
test_try_again_stop_fires_async and the async tests at the referenced later
range with the existing _noop awaitable coroutine. Keep the retry parameters and
assertions unchanged.
- Around line 563-682: Extract the repeated _OneShotWait definitions into one
module-level helper, preserving the current one-shot StopIteration behavior and
allowing the returned interval to be configured via an interval parameter.
Replace all four local definitions in the shown retry tests and the three
corresponding definitions in TestFastPathMore with the shared helper, passing
any required interval values.
- Around line 353-451: Strengthen the slow-path tests in
test_rate_limit_alone_forces_slow_path, test_on_success_forces_slow_path,
test_on_backoff_forces_slow_path, and test_on_giveup_forces_slow_path so they
verify slow-path-specific behavior rather than only exception propagation.
Assert the relevant callback invocation counts, including the collected calls in
the success and give-up tests, or directly validate _is_fast_path(...) is False
for each configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 72c36cbb-f04d-4faa-b50d-4ccc8869ee4f

📥 Commits

Reviewing files that changed from the base of the PR and between d19cdca and 8a43dca.

📒 Files selected for processing (12)
  • AGENTS.md
  • CHANGELOG.md
  • backon/_common.py
  • backon/_decorator.py
  • backon/_retry/_decide.py
  • backon/_retry/_helpers.py
  • backon/_typing.py
  • pyproject.toml
  • tests/test_backon_sync.py
  • tests/test_coverage_gaps.py
  • tests/test_edge_cases.py
  • tests/test_fast_path.py

Comment thread CHANGELOG.md
## 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.

Comment thread tests/test_edge_cases.py
Comment on lines +1077 to +1099
async def test_on_exception_async_gen_disabled(self):
backon.disable()
calls = []

@backon.on_exception(
backon.constant,
ValueError,
max_tries=3,
jitter=None,
interval=0.01,
sleep=lambda s: None,
logger=None,
)
async def gen():
calls.append(1)
yield 42

result = []
async for item in gen():
result.append(item)
assert result == [42]
assert len(calls) == 1
backon.enable()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wrap backon.disable() in try/finally to avoid leaking global state.

If any assertion (or the generator iteration) fails, backon.enable() never runs and every subsequent test in the session sees retries disabled. The equivalent tests in tests/test_fast_path.py (Lines 685-693) already use try/finally.

🛡️ Proposed fix (sync case; apply the same shape to the async one)
     def test_on_exception_sync_gen_disabled(self):
         backon.disable()
-        calls = []
-        ...
-        result = list(gen())
-        assert result == [42]
-        assert len(calls) == 1
-        backon.enable()
+        try:
+            calls = []
+            ...
+            result = list(gen())
+            assert result == [42]
+            assert len(calls) == 1
+        finally:
+            backon.enable()

Also applies to: 1101-1121

🤖 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 `@tests/test_edge_cases.py` around lines 1077 - 1099, Update
test_on_exception_async_gen_disabled so backon.disable() is paired with a
try/finally block, placing the existing generator iteration and assertions in
the protected section and restoring global state with backon.enable() in
finally. Apply the same cleanup structure to the related test covering lines
1101-1121, preserving its existing assertions and behavior.

Comment thread tests/test_fast_path.py
Comment on lines +753 to +772
async def test_fast_inner_wait_gen_kwargs_none_async(self):
from backon._retry._fast import _retry_fast_async_inner

calls = []

async def target():
calls.append(1)
if len(calls) < 2:
raise ValueError("fail")
return "ok"

result = await _retry_fast_async_inner(
target,
backon.constant,
condition=backon.retry_if_exception_type(ValueError),
max_tries=3,
jitter=None,
wait_gen_kwargs=None,
)
assert result == "ok"

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

Async test sleeps for the wait generator's default interval.

Unlike its sync counterpart (695-714), this test passes no sleep, so _retry_fast_async_inner falls back to asyncio.sleep and the single retry waits constant's default interval — well over the 100ms per-test budget. Same applies to the awaited default in any other async fast-inner test without a sleep override.

🐛 Proposed fix
+        async def _noop(s):
+            pass
+
         result = await _retry_fast_async_inner(
             target,
             backon.constant,
             condition=backon.retry_if_exception_type(ValueError),
             max_tries=3,
             jitter=None,
+            sleep=_noop,
             wait_gen_kwargs=None,
         )

As per coding guidelines, "keep every test under 100ms using jitter=None and small intervals".

📝 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
async def test_fast_inner_wait_gen_kwargs_none_async(self):
from backon._retry._fast import _retry_fast_async_inner
calls = []
async def target():
calls.append(1)
if len(calls) < 2:
raise ValueError("fail")
return "ok"
result = await _retry_fast_async_inner(
target,
backon.constant,
condition=backon.retry_if_exception_type(ValueError),
max_tries=3,
jitter=None,
wait_gen_kwargs=None,
)
assert result == "ok"
async def test_fast_inner_wait_gen_kwargs_none_async(self):
from backon._retry._fast import _retry_fast_async_inner
calls = []
async def target():
calls.append(1)
if len(calls) < 2:
raise ValueError("fail")
return "ok"
async def _noop(s):
pass
result = await _retry_fast_async_inner(
target,
backon.constant,
condition=backon.retry_if_exception_type(ValueError),
max_tries=3,
jitter=None,
sleep=_noop,
wait_gen_kwargs=None,
)
assert result == "ok"
🤖 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 `@tests/test_fast_path.py` around lines 753 - 772, Update
test_fast_inner_wait_gen_kwargs_none_async and any other async
_retry_fast_async_inner tests lacking a sleep override to inject a no-op or very
small async sleep while retaining jitter=None, so retries avoid the wait
generator’s default interval and each test stays under 100ms.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant