Skip to content

Fix rerun filtering for teardown exceptions - #385

Open
hfycium wants to merge 3 commits into
pytest-dev:masterfrom
hfycium:codex/fix-teardown-only-rerun
Open

hfycium wants to merge 3 commits into
pytest-dev:masterfrom
hfycium:codex/fix-teardown-only-rerun

Conversation

@hfycium

@hfycium hfycium commented Oct 6, 2026

Copy link
Copy Markdown

Fixes #261

When a test call fails with an exception excluded by only_rerun, a fixture teardown can still raise an exception that matches the rerun filter. The plugin should rerun the test without retaining the earlier call failure in the final result.

This change:

  • keeps terminal-error handling correct for each test phase;
  • suppresses an earlier non-matching phase report only when a later phase can trigger the rerun;
  • adds a regression test and changelog entry.

Checks:

  • tox -e py312-pytest90 (246 passed, 10 skipped)
  • tox -e linting (all hooks passed)

@icemac icemac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review (high effort): 8 findings, see inline comments.

— Comment created by Claude

Comment thread src/pytest_rerunfailures.py Outdated
condition
and not _reruns_condition_matches_phase(item, report.when)
and not _rerun_matches_phase(item, report.when)
and _rerun_matches_later_phase(item, report.when)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The last attempt drops the real call failure. This continue branch does not check whether reruns are left. On the last attempt (execution_count > reruns), the call report is still suppressed because a later phase "matched", but _should_not_rerun then logs the teardown report as a final error, so nothing ever reports the call failure.

Repro: @pytest.mark.flaky(reruns=1, only_rerun=["ValueError"]), where the test always raises AssertionError and its fixture teardown always raises ValueError.

  • master: 1 failed, 1 error
  • this PR: 1 error, 1 rerun. The AssertionError is never reported, and the test is not counted as failed.

This branch should only apply when a rerun will actually happen (reruns left, suite cap not reached, and no veto from _should_not_rerun for the later phase).

— Comment created by Claude

Comment thread src/pytest_rerunfailures.py Outdated
phases = ("setup", "call", "teardown")
phase_index = phases.index(phase)
return any(
_rerun_matches_phase(item, failed_phase)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Terminal setup errors get swallowed. _rerun_matches_later_phase ignores the terminal errors of earlier phases, but _should_not_rerun (teardown branch) still vetoes on _terminal_errors["setup"]. If setup fails with a non-matching error and teardown raises a matching one, the setup report hits continue here, and then the teardown report is logged as final with no rerun.

Repro: only_rerun=["ValueError"], where one fixture raises AssertionError in setup and another raises ValueError in teardown.

  • master: 2 errors (the setup AssertionError is shown)
  • this PR: 1 error. Only the teardown ValueError is shown, and the setup failure disappears.

— Comment created by Claude

Comment thread src/pytest_rerunfailures.py Outdated
def _should_not_rerun(item, report, reruns, condition):
xfail = hasattr(report, "wasxfail")
is_terminal_error = any(item._terminal_errors.values())
if report.when == "teardown":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This bypasses the rerun_except veto. For teardown, a terminal error in the call phase is now ignored. With rerun_except, a terminal call error means "this exception must never be rerun". With this change, any unrelated teardown failure overrides that and reruns the test.

Repro: @pytest.mark.flaky(reruns=1, rerun_except=["AssertionError"]). The first attempt fails with assert False, "real bug", and its fixture teardown raises RuntimeError.

  • master: 1 failed, 1 error
  • this PR: 1 passed, 1 rerun. The excluded AssertionError is rerun and hidden.

The override probably only makes sense when the call error is terminal because it misses only_rerun, not when it matches rerun_except.

— Comment created by Claude

Comment thread src/pytest_rerunfailures.py Outdated
if report.when == "teardown":
is_terminal_error = item._terminal_errors.get(
"setup", False
) or item._terminal_errors.get("teardown", False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Higher-scoped fixtures are torn down and set up again before the new rerun. pytest_runtest_teardown (around L1229) still refuses to suspend finalizers when any(item._terminal_errors.values()). So when a terminal call error is followed by a matching teardown error, which is the new rerun path, module, class and session fixtures are not held back. They get torn down and then set up again for the rerun.

Repro: a module-scoped fixture that prints on setup and teardown, used by the only test in the module. The new test scenario prints module setup/module teardown twice. This is the double teardown that test_..._module teardown count == 1 (just above the new test) guards against. That guard and the matching check in pytest_runtest_makereport need to follow the new rule too.

— Comment created by Claude

Comment thread src/pytest_rerunfailures.py Outdated
elif (
condition
and not _reruns_condition_matches_phase(item, report.when)
and not _rerun_matches_phase(item, report.when)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Altitude: each phase report now decides on its own (continue / _should_not_rerun with a teardown special case / _rerun_matches_later_phase), and the three checks have to stay in sync by hand. The three bugs above come from them drifting apart. A simpler approach: after runtestprotocol returns, decide once whether this attempt reruns, based on all failed phases, terminal errors, condition, remaining reruns and the suite cap. Then either mark every failed report as rerun or log them all normally. That also keeps the call failure's traceback in the rerun report instead of silently dropping it.

— Comment created by Claude

Comment thread src/pytest_rerunfailures.py Outdated
and not _reruns_condition_matches_phase(item, report.when)
and not _rerun_matches_phase(item, report.when)
and _rerun_matches_later_phase(item, report.when)
and (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Efficiency: _rerun_matches_later_phase comes before the cheap report.failed check in this and chain. So it runs for every passing report, and each time it rebuilds _get_reruns_condition_failures(item), which was already computed for condition a few lines above. Moving the report.failed/subtests check first, and passing the already computed failures list in, avoids this.

— Comment created by Claude

Comment thread src/pytest_rerunfailures.py Outdated

def _rerun_matches_later_phase(item, phase):
"""Return whether a later failed phase can trigger a rerun."""
phases = ("setup", "call", "teardown")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reuse: this phase-order tuple duplicates _phase_order() (and the tuple in _get_reruns_condition_failures). Comparing with _phase_order(failed_phase) > _phase_order(phase) keeps a single definition of the ordering.

— Comment created by Claude

Comment thread tests/test_pytest_rerunfailures.py Outdated

result = testdir.runpytest()

assert_outcomes(result, passed=1, rerun=1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test gap: the test only covers the case where the rerun succeeds. A case with no reruns left (both attempts fail in call and teardown) would have caught that the call failure is dropped on the last attempt. Cases for rerun_except and a module-scoped fixture would cover the other regressions noted above.

— Comment created by Claude

@hfycium

hfycium commented Oct 8, 2026

Copy link
Copy Markdown
Author

Pushed dc6cfa1 with the requested fix. Rerun decisions are now made once from all failed phases, terminal rerun-except errors, xfail state, remaining reruns, and the suite cap; failed setup/call/teardown reports are kept together instead of being dropped when a later phase matches. Added regression coverage for last-attempt call/setup failures and rerun-except veto, plus a changelog entry. Validation: 7 targeted cases passed; 261 tests in tests/test_pytest_rerunfailures.py passed (1 pre-existing local pytest 9 summary assertion deselected).

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.

only_rerun with exception raised from fixture teardown reruns test but report previous runs as failure

2 participants