Skip to content
Open
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
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ urls = { Homepage = "https://github.com/pytest-dev/pytest-rerunfailures" }

entry-points.pytest11.rerunfailures = "pytest_rerunfailures"

[dependency-groups]
dev = [
"mypy>=2.1",
]

[tool.setuptools.dynamic]
readme = { file = [ "HEADER.rst", "README.rst", "CHANGES.rst" ] }

Expand All @@ -72,3 +77,12 @@ lint.pydocstyle.convention = "google"

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

[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true

[[tool.mypy.overrides]]
module = [ "xdist.newhooks" ]
ignore_missing_imports = true
104 changes: 98 additions & 6 deletions 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 thread
Borda marked this conversation as resolved.
Comment on lines +124 to +132

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.

I'd also prefer to call it --max-suite-reruns to make it consistent. (The repository itself also contains rerun in its name.


arg_type = "string"
parser.addini("reruns", RERUNS_DESC, type=arg_type)
Expand All @@ -134,6 +144,11 @@ def check_options(config):
if config.option.reruns != 0:
if config.option.usepdb: # a core option
raise pytest.UsageError("--reruns incompatible with --pdb")
if (
config.option.max_suite_retries is not None
and config.option.max_suite_retries < 0
):
raise pytest.UsageError("--max-suite-retries must be >= 0")
Comment on lines 144 to +151


def _get_marker(item):
Expand Down Expand Up @@ -427,9 +442,32 @@ def pytest_handlecrashitem(self, crashitem, report, sched):
# and failures (set after each failure or crash)
# accessible from both the master and worker
class StatusDB:
def __init__(self):
self.delim = b"\n"
self.hmap = {}
def __init__(self) -> None:
self.delim: bytes = b"\n"
self.hmap: dict[str, str] = {}
self._suite_rerun_count: int = 0
self._suite_lock: threading.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.

Reads under lock for thread safety.
"""
with self._suite_lock:
return self._suite_rerun_count

def _hash(self, crashitem: str) -> str:
if crashitem not in self.hmap:
Expand Down Expand Up @@ -486,12 +524,12 @@ def _sock_send(self, conn, msg: str):


class ServerStatusDB(SocketDB):
def __init__(self):
def __init__(self) -> None:
super().__init__()
self.sock.bind(("127.0.0.1", 0))
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

self.rerunfailures_db = {}
self.rerunfailures_db: dict[str, dict[str, int]] = {}
t = threading.Thread(target=self.run_server, daemon=True)
t.start()

Expand All @@ -514,6 +552,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 +577,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 +609,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))

suspended_finalizers = {}
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"

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 +723,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.
Comment on lines +726 to +731

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

Expand Down
70 changes: 70 additions & 0 deletions tests/test_pytest_rerunfailures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1526,3 +1526,73 @@ 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")
assert_outcomes(result, passed=0, failed=3, rerun=4)


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