Skip to content
Open
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
8 changes: 7 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ Changelog
16.4 (unreleased)
-----------------

- Nothing changed yet.
Features
++++++++

- Add ``--max-suite-retries`` option to cap the total number of reruns across
the entire test suite. Once the limit is reached, no further reruns occur
regardless of per-test ``--reruns`` or ``@pytest.mark.flaky`` settings.
Fixes `#298 <https://github.com/pytest-dev/pytest-rerunfailures/issues/298>`_.


16.3 (2026-05-22)
Expand Down
15 changes: 15 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,21 @@ setting. To make them additive instead, pass ``--reruns-mode=append``. With

$ pytest --reruns 4 --reruns-mode append

Limit total reruns across the suite
------------------------------------

To cap the total number of reruns across the entire test suite regardless of
how many individual tests fail, pass ``--max-suite-retries``. Once the limit
is reached, no further reruns occur even if individual tests have remaining
retries:

.. code-block:: bash

$ pytest --reruns 3 --max-suite-retries 10

This is useful in large test suites to bound resource usage when many tests
are flaky at the same time.

Show tracebacks for retried failures
------------------------------------

Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,8 @@ lint.pydocstyle.convention = "google"

[tool.check-manifest]
ignore = [ ".pre-commit-config.yaml" ]

[dependency-groups]
dev = [
"mypy>=2.1.0",
]
85 changes: 84 additions & 1 deletion src/pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import traceback
import warnings
from contextlib import suppress
from typing import Any

import pytest
from _pytest.outcomes import fail
Expand Down Expand Up @@ -120,6 +121,15 @@ def pytest_addoption(parser):
"'rerun test summary info' section, which is emitted automatically "
"when this flag is set.",
)
group.addoption(
"--max-suite-retries",
action="store",
dest="max_suite_retries",
type=int,
default=None,
help="Maximum total number of reruns across the entire test suite. "
"Once this limit is reached, no further reruns will occur.",
)
Comment on lines +124 to +132

arg_type = "string"
parser.addini("reruns", RERUNS_DESC, type=arg_type)
Expand Down Expand Up @@ -430,6 +440,25 @@ class StatusDB:
def __init__(self):
self.delim = b"\n"
self.hmap = {}
self._suite_rerun_count = 0
self._suite_lock = threading.Lock()

def increment_suite_reruns(self) -> int:
"""Atomically increment the suite-wide rerun counter; return new total."""
with self._suite_lock:
self._suite_rerun_count += 1
return self._suite_rerun_count

def try_increment_suite_reruns(self, max_cap: int) -> bool:
with self._suite_lock:
if self._suite_rerun_count < max_cap:
self._suite_rerun_count += 1
return True
return False

def get_suite_reruns(self) -> int:
"""Return the current suite-wide rerun count."""
return self._suite_rerun_count
Comment thread
icemac marked this conversation as resolved.
Comment on lines +459 to +461

def _hash(self, crashitem: str) -> str:
if crashitem not in self.hmap:
Expand Down Expand Up @@ -514,6 +543,19 @@ def run_connection(self, conn):
self._set(i, k, int(v))
elif op == "get":
self._sock_send(conn, str(self._get(i, k)))
elif op == "inc":
with self._suite_lock:
new_v = self._get(i, k) + 1
self._set(i, k, new_v)
self._sock_send(conn, str(new_v))
Comment thread
icemac marked this conversation as resolved.
elif op == "try_inc":
with self._suite_lock:
current = self._get(i, k)
if current < int(v):
self._set(i, k, current + 1)
self._sock_send(conn, "1")
else:
self._sock_send(conn, "0")

def _set(self, i: str, k: str, v: int):
if i not in self.rerunfailures_db:
Expand All @@ -526,6 +568,25 @@ def _get(self, i: str, k: str) -> int:
except KeyError:
return 0

def increment_suite_reruns(self) -> int:
"""Atomically increment the suite-wide rerun counter; return new total."""
with self._suite_lock:
new_v = self._get("__suite__", "r") + 1
self._set("__suite__", "r", new_v)
return new_v

def try_increment_suite_reruns(self, max_cap: int) -> bool:
with self._suite_lock:
current = self._get("__suite__", "r")
if current < max_cap:
self._set("__suite__", "r", current + 1)
return True
return False

def get_suite_reruns(self) -> int:
"""Return the current suite-wide rerun count."""
return self._get("__suite__", "r")


class ClientStatusDB(SocketDB):
def __init__(self, sock_port):
Expand All @@ -539,8 +600,23 @@ def _get(self, i: str, k: str) -> int:
self._sock_send(self.sock, "|".join(("get", i, k, "")))
return int(self._sock_recv(self.sock))

def increment_suite_reruns(self) -> int:
"""Atomically increment the suite-wide rerun counter; return new total."""
self._sock_send(self.sock, "|".join(("inc", "__suite__", "r", "")))
return int(self._sock_recv(self.sock))

def try_increment_suite_reruns(self, max_cap: int) -> bool:
self._sock_send(
self.sock, "|".join(("try_inc", "__suite__", "r", str(max_cap)))
)
return self._sock_recv(self.sock) == "1"

suspended_finalizers = {}
def get_suite_reruns(self) -> int:
"""Return the current suite-wide rerun count."""
return self._get("__suite__", "r")


suspended_finalizers: dict[Any, Any] = {}


def pytest_runtest_teardown(item, nextitem):
Expand Down Expand Up @@ -638,6 +714,13 @@ def pytest_runtest_protocol(item, nextitem):
item.ihook.pytest_runtest_logreport(report=report)
else:
# failure detected and reruns not exhausted, since i < reruns
max_suite_reruns = item.session.config.option.max_suite_retries
if max_suite_reruns is not None:
if not db.try_increment_suite_reruns(max_suite_reruns):
# suite-wide limit exhausted — log as final failure
item.ihook.pytest_runtest_logreport(report=report)
continue
Comment thread
Borda marked this conversation as resolved.

report.outcome = "rerun"
time.sleep(delay)

Expand Down
72 changes: 72 additions & 0 deletions tests/test_pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1526,3 +1526,75 @@ def test_pass():

result = testdir.runpytest("--reruns-mode", "bogus")
assert result.ret != 0


def test_max_suite_retries_caps_total_reruns(testdir):
"""Suite limit stops reruns once the total across all tests is reached."""
testdir.makepyfile(
"""
def test_fail_1():
assert False

def test_fail_2():
assert False

def test_fail_3():
assert False
"""
)
# 3 tests each allowed up to 3 reruns, but suite cap is 4 total
result = testdir.runpytest("--reruns", "3", "--max-suite-retries", "4")
outcomes = result.parseoutcomes()
assert outcomes.get("rerun", 0) == 4
assert outcomes.get("failed", 0) == 3
Comment on lines +1547 to +1549


def test_max_suite_retries_does_not_limit_when_sufficient(testdir):
"""Suite limit has no effect when total reruns stay below the cap."""
testdir.makepyfile(
"""
def test_fail():
assert False
"""
)
result = testdir.runpytest("--reruns", "2", "--max-suite-retries", "10")
assert_outcomes(result, passed=0, failed=1, rerun=2)


def test_max_suite_retries_zero_disables_all_reruns(testdir):
"""Suite limit of 0 prevents any reruns from occurring."""
testdir.makepyfile(
"""
def test_fail():
assert False
"""
)
result = testdir.runpytest("--reruns", "3", "--max-suite-retries", "0")
assert_outcomes(result, passed=0, failed=1, rerun=0)


def test_max_suite_retries_works_with_passing_tests(testdir):
"""Suite limit only counts actual reruns, not passing test runs."""
testdir.makepyfile(
"""
def test_pass():
assert True

def test_fail():
assert False
"""
)
result = testdir.runpytest("--reruns", "3", "--max-suite-retries", "2")
assert_outcomes(result, passed=1, failed=1, rerun=2)


def test_max_suite_retries_without_reruns_has_no_effect(testdir):
"""--max-suite-retries alone (without --reruns) does not break anything."""
testdir.makepyfile(
"""
def test_fail():
assert False
"""
)
result = testdir.runpytest("--max-suite-retries", "5")
assert_outcomes(result, passed=0, failed=1, rerun=0)
Loading