From 42a29635fa9802bf86d0c0609b8ab60fff1624a0 Mon Sep 17 00:00:00 2001 From: Teddy Tennant Date: Sun, 6 Sep 2026 15:48:18 -0400 Subject: [PATCH] Match only_rerun and rerun_except against the exception chain Wrapped errors such as raise RuntimeError(...) from MemoryError(...) were matched on the outer type and message only, so --only-rerun MemoryError did not retry them. --- README.rst | 5 ++ changes/353.bugfix.rst | 1 + src/pytest_rerunfailures.py | 21 ++++++-- tests/test_pytest_rerunfailures.py | 81 ++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 changes/353.bugfix.rst diff --git a/README.rst b/README.rst index c29dc9c..15a1b83 100644 --- a/README.rst +++ b/README.rst @@ -110,6 +110,11 @@ would only rerun those errors that match ``AssertionError`` or ``ValueError``: $ pytest --reruns 5 --only-rerun AssertionError --only-rerun ValueError +The same matching is applied to each exception in the ``__cause__`` / +``__context__`` chain, so a wrapped error such as +``raise RuntimeError(...) from MemoryError(...)`` is still rerun by +``--only-rerun MemoryError``. + Re-run all failures other than matching certain expressions ----------------------------------------------------------- diff --git a/changes/353.bugfix.rst b/changes/353.bugfix.rst new file mode 100644 index 0000000..09af968 --- /dev/null +++ b/changes/353.bugfix.rst @@ -0,0 +1 @@ +Match ``only_rerun`` and ``rerun_except`` against the full exception chain (``__cause__`` and ``__context__``), not only the outermost exception. diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 98934bc..896b56d 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -522,12 +522,27 @@ def _matches_any_rerun_except_error(rerun_except_errors, excinfo): return _try_match_error(rerun_except_errors, excinfo) +def _iter_exception_chain(exc): + seen = set() + while exc is not None and id(exc) not in seen: + seen.add(id(exc)) + yield exc + if exc.__cause__ is not None: + exc = exc.__cause__ + elif not getattr(exc, "__suppress_context__", False): + exc = exc.__context__ + else: + exc = None + + def _try_match_error(rerun_errors, excinfo): - if excinfo: - err = f"{excinfo.type.__name__}: {excinfo.value}" + if not excinfo: + return False + for exc in _iter_exception_chain(excinfo.value): + err = f"{type(exc).__name__}: {exc}" for rerun_error in rerun_errors: if isinstance(rerun_error, type) and issubclass(rerun_error, BaseException): - if issubclass(excinfo.type, rerun_error): + if isinstance(exc, rerun_error): return True elif re.search(rerun_error, err): return True diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index 63f3455..85550d0 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -1068,6 +1068,87 @@ def test_only_rerun2(): ) +@pytest.mark.parametrize( + "only_rerun,should_rerun", + [ + ("MemoryError", True), + ("out of memory", True), + ("ValueError", False), + ], +) +def test_only_rerun_matches_wrapped_cause(testdir, only_rerun, should_rerun): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError as error: + raise RuntimeError("something failed") from error + """ + ) + result = testdir.runpytest("--reruns", "1", "--only-rerun", only_rerun) + assert_outcomes(result, passed=0, failed=1, rerun=1 if should_rerun else 0) + + +def test_only_rerun_matches_implicit_context(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError: + raise RuntimeError("something failed") + """ + ) + result = testdir.runpytest("--reruns", "1", "--only-rerun", "MemoryError") + assert_outcomes(result, passed=0, failed=1, rerun=1) + + +def test_only_rerun_exception_class_matches_wrapped_cause(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.flaky(reruns=1, only_rerun=[MemoryError]) + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError as error: + raise RuntimeError("something failed") from error + """ + ) + result = testdir.runpytest() + assert_outcomes(result, passed=0, failed=1, rerun=1) + + +def test_only_rerun_ignores_suppressed_context(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise MemoryError("out of memory") + except MemoryError: + raise RuntimeError("something failed") from None + """ + ) + result = testdir.runpytest("--reruns", "1", "--only-rerun", "MemoryError") + assert_outcomes(result, passed=0, failed=1, rerun=0) + + +def test_rerun_except_matches_wrapped_cause(testdir): + testdir.makepyfile( + """ + def test_wrapped(): + try: + raise ValueError("bad value") + except ValueError as error: + raise RuntimeError("something failed") from error + """ + ) + result = testdir.runpytest("--reruns", "1", "--rerun-except", "ValueError") + assert_outcomes(result, passed=0, failed=1, rerun=0) + + def test_no_rerun_on_strict_xfail_with_only_rerun_flag(testdir): testdir.makepyfile( """