From 1066c3da8ed44a034d9668edf230729c8fba6af3 Mon Sep 17 00:00:00 2001 From: Dmitrii Ovsyannikov Date: Sat, 20 Jun 2026 21:00:19 +0300 Subject: [PATCH 1/2] Add --reruns-delay-backoff-factor for exponential rerun backoff Add a reruns-delay-backoff-factor option (with matching reruns_delay_backoff_factor marker kwarg / ini setting) that multiplies the rerun delay after each attempt, so the delay before the n-th re-run is reruns_delay * factor ** (n - 1). Default factor is 1.0, keeping the current constant-delay behaviour unchanged. Includes tests (CLI, marker, negative-factor warning), README docs and a changelog entry. --- CHANGES.rst | 7 +++ README.rst | 20 ++++++++- src/pytest_rerunfailures.py | 54 ++++++++++++++++++++-- tests/test_pytest_rerunfailures.py | 72 ++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 88dc082..fcf37f5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -18,6 +18,13 @@ Features and later. The pytest-subtests plugin is *not* supported. Fixes `#315 `_. +- Add ``--reruns-delay-backoff-factor`` option (and the matching + ``reruns_delay_backoff_factor`` marker kwarg / ini setting) to grow the rerun + delay after each attempt for an exponential backoff. The delay before the + *n*-th re-run is ``reruns_delay * reruns_delay_backoff_factor ** (n - 1)``. + The default factor is ``1.0``, so existing behaviour (a constant delay) is + unchanged. + 16.3 (2026-05-22) ----------------- diff --git a/README.rst b/README.rst index e65152f..fda92d1 100644 --- a/README.rst +++ b/README.rst @@ -82,6 +82,16 @@ test re-run is launched: $ pytest --reruns 5 --reruns-delay 1 +To grow the delay after every attempt (exponential backoff), use the +``--reruns-delay-backoff-factor`` option. The delay before the *n*-th re-run is +``reruns-delay * reruns-delay-backoff-factor ** (n - 1)``. The default factor is +``1.0``, which keeps the delay constant. For example, the following waits 1, 2 +and 4 seconds before the three re-runs: + +.. code-block:: bash + + $ pytest --reruns 3 --reruns-delay 1 --reruns-delay-backoff-factor 2 + Re-run all failures matching certain expressions ------------------------------------------------ @@ -135,7 +145,8 @@ test to run: Note that when teardown fails, two reports are generated for the case, one for the test case and the other for the teardown error. -You can also specify the re-run delay time in the marker: +You can also specify the re-run delay time in the marker, as well as the +backoff factor for an exponential backoff: .. code-block:: python @@ -144,6 +155,13 @@ You can also specify the re-run delay time in the marker: import random assert random.choice([True, False]) +.. code-block:: python + + @pytest.mark.flaky(reruns=5, reruns_delay=2, reruns_delay_backoff_factor=2) + def test_example(): + import random + assert random.choice([True, False]) + You can also specify an optional ``condition`` in the re-run marker: .. code-block:: python diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 0117647..c9a36b1 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -52,6 +52,11 @@ def works_with_current_xdist(): RERUNS_DESC = "number of times to re-run failed tests. defaults to 0." RERUNS_DELAY_DESC = "add time (seconds) delay between reruns." +RERUNS_DELAY_BACKOFF_FACTOR_DESC = ( + "multiply the rerun delay by this factor after each attempt, for an " + "exponential backoff (delay * factor ** (attempt - 1)). defaults to 1.0, " + "i.e. a constant delay." +) # command line options @@ -91,6 +96,13 @@ def pytest_addoption(parser): type=float, help="add time (seconds) delay between reruns.", ) + group._addoption( + "--reruns-delay-backoff-factor", + action="store", + dest="reruns_delay_backoff_factor", + type=float, + help=RERUNS_DELAY_BACKOFF_FACTOR_DESC, + ) group._addoption( "--rerun-except", action="append", @@ -132,6 +144,11 @@ def pytest_addoption(parser): arg_type = "string" parser.addini("reruns", RERUNS_DESC, type=arg_type) parser.addini("reruns_delay", RERUNS_DELAY_DESC, type=arg_type) + parser.addini( + "reruns_delay_backoff_factor", + RERUNS_DELAY_BACKOFF_FACTOR_DESC, + type=arg_type, + ) # making sure the options make sense @@ -213,6 +230,33 @@ def get_reruns_delay(item): return delay +def get_reruns_delay_backoff_factor(item): + rerun_marker = _get_marker(item) + + if ( + rerun_marker is not None + and "reruns_delay_backoff_factor" in rerun_marker.kwargs + ): + factor = rerun_marker.kwargs["reruns_delay_backoff_factor"] + else: + factor = item.session.config.getvalue("reruns_delay_backoff_factor") + if factor is None: + try: + factor = float( + item.session.config.getini("reruns_delay_backoff_factor") + ) + except (TypeError, ValueError): + factor = 1.0 + + if factor < 0: + factor = 1.0 + warnings.warn( + "Rerun delay backoff factor cannot be < 0. Using default value: 1.0" + ) + + return factor + + def get_reruns_condition(item): rerun_marker = _get_marker(item) @@ -476,9 +520,10 @@ def pytest_configure(config): # add flaky marker config.addinivalue_line( "markers", - "flaky(reruns=1, reruns_delay=0): mark test to re-run up " - "to 'reruns' times. Add a delay of 'reruns_delay' seconds " - "between re-runs.", + "flaky(reruns=1, reruns_delay=0, reruns_delay_backoff_factor=1.0): mark " + "test to re-run up to 'reruns' times. Add a delay of 'reruns_delay' " + "seconds between re-runs, multiplied by 'reruns_delay_backoff_factor' " + "after each attempt for an exponential backoff.", ) if config.pluginmanager.hasplugin("xdist") and HAS_PYTEST_HANDLECRASHITEM: @@ -718,6 +763,7 @@ def pytest_runtest_protocol(item, nextitem): # first item if necessary check_options(item.session.config) delay = get_reruns_delay(item) + delay_backoff_factor = get_reruns_delay_backoff_factor(item) parallel = not is_master(item.config) db = item.session.config.failures_db item.execution_count = db.get_test_failures(item.nodeid) @@ -740,7 +786,7 @@ def pytest_runtest_protocol(item, nextitem): else: # failure detected and reruns not exhausted, since i < reruns report.outcome = "rerun" - time.sleep(delay) + time.sleep(delay * delay_backoff_factor ** (item.execution_count - 1)) if not parallel or works_with_current_xdist(): # will rerun test, log intermediate result diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index 21a3bf5..4f5f37f 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -466,6 +466,78 @@ def test_fail_two(): assert_outcomes(result, passed=0, failed=1, rerun=2) +def test_reruns_with_delay_backoff_factor(testdir): + testdir.makepyfile( + """ + def test_fail(): + assert False""" + ) + + time.sleep = mock.MagicMock() + + result = testdir.runpytest( + "--reruns", + "3", + "--reruns-delay", + "1", + "--reruns-delay-backoff-factor", + "2", + ) + + # delay * factor ** (attempt - 1) -> 1, 2, 4 + assert time.sleep.call_args_list == [mock.call(1), mock.call(2), mock.call(4)] + + assert_outcomes(result, passed=0, failed=1, rerun=3) + + +def test_reruns_with_delay_backoff_factor_marker(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.flaky(reruns=3, reruns_delay=1, reruns_delay_backoff_factor=2) + def test_fail(): + assert False""" + ) + + time.sleep = mock.MagicMock() + + result = testdir.runpytest() + + assert time.sleep.call_args_list == [mock.call(1), mock.call(2), mock.call(4)] + + assert_outcomes(result, passed=0, failed=1, rerun=3) + + +def test_reruns_with_negative_delay_backoff_factor(testdir): + testdir.makepyfile( + """ + def test_fail(): + assert False""" + ) + + time.sleep = mock.MagicMock() + + result = testdir.runpytest( + "--reruns", + "2", + "--reruns-delay", + "1", + "--reruns-delay-backoff-factor", + "-1", + ) + + result.stdout.fnmatch_lines( + "*UserWarning: Rerun delay backoff factor cannot be < 0. " + "Using default value: 1.0" + ) + + # factor falls back to 1.0 -> constant delay of 1 + assert time.sleep.call_args_list == [mock.call(1), mock.call(1)] + + assert_outcomes(result, passed=0, failed=1, rerun=2) + + def test_rerun_on_setup_class_with_error_with_reruns(testdir): """ Case: setup_class throwing error on the first execution for parametrized test From d769995c39e3ad445f0fbb4a98031fd38abb7313 Mon Sep 17 00:00:00 2001 From: ovsds Date: Fri, 26 Jun 2026 10:30:53 +0200 Subject: [PATCH 2/2] Address review: mirror get_reruns_delay for backoff factor marker - Support positional third flaky() arg for the backoff factor and default to 1.0 when a marker is present without the factor (mirrors get_reruns_delay) - Add positional-arg marker test - Use underscore identifiers in README backoff formula --- README.rst | 2 +- src/pytest_rerunfailures.py | 13 ++++++++----- tests/test_pytest_rerunfailures.py | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index fda92d1..b4d73c6 100644 --- a/README.rst +++ b/README.rst @@ -84,7 +84,7 @@ test re-run is launched: To grow the delay after every attempt (exponential backoff), use the ``--reruns-delay-backoff-factor`` option. The delay before the *n*-th re-run is -``reruns-delay * reruns-delay-backoff-factor ** (n - 1)``. The default factor is +``reruns_delay * reruns_delay_backoff_factor ** (n - 1)``. The default factor is ``1.0``, which keeps the delay constant. For example, the following waits 1, 2 and 4 seconds before the three re-runs: diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index c9a36b1..4a3e0b1 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -233,11 +233,14 @@ def get_reruns_delay(item): def get_reruns_delay_backoff_factor(item): rerun_marker = _get_marker(item) - if ( - rerun_marker is not None - and "reruns_delay_backoff_factor" in rerun_marker.kwargs - ): - factor = rerun_marker.kwargs["reruns_delay_backoff_factor"] + if rerun_marker is not None: + if "reruns_delay_backoff_factor" in rerun_marker.kwargs: + factor = rerun_marker.kwargs["reruns_delay_backoff_factor"] + elif len(rerun_marker.args) > 2: + # check for arguments + factor = rerun_marker.args[2] + else: + factor = 1.0 else: factor = item.session.config.getvalue("reruns_delay_backoff_factor") if factor is None: diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index 4f5f37f..e363f14 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -509,6 +509,25 @@ def test_fail(): assert_outcomes(result, passed=0, failed=1, rerun=3) +def test_reruns_with_delay_backoff_factor_marker_positional(testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.flaky(3, 1, 2) + def test_fail(): + assert False""" + ) + + time.sleep = mock.MagicMock() + + result = testdir.runpytest() + + assert time.sleep.call_args_list == [mock.call(1), mock.call(2), mock.call(4)] + + assert_outcomes(result, passed=0, failed=1, rerun=3) + + def test_reruns_with_negative_delay_backoff_factor(testdir): testdir.makepyfile( """