diff --git a/doc/source/index.rst b/doc/source/index.rst index ba21fd93..59062d3d 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -201,6 +201,17 @@ increasing jitter helps minimise collisions. raise Exception +For a gentler backoff curve than pure exponential, Fibonacci backoff increases +wait times following the Fibonacci sequence (1, 2, 3, 5, 8, 13, …). + +.. testcode:: + + @retry(wait=wait_fibonacci(max=60)) + def wait_fibonacci_up_to_60s(): + print("Wait 1s, 2s, 3s, 5s, 8s, 13s, ... up to 60s between retries") + raise Exception + + Sometimes it's necessary to build a chain of backoffs. .. testcode:: diff --git a/releasenotes/notes/wait-fibonacci-55c6d584f5a34432.yaml b/releasenotes/notes/wait-fibonacci-55c6d584f5a34432.yaml new file mode 100644 index 00000000..df5199c4 --- /dev/null +++ b/releasenotes/notes/wait-fibonacci-55c6d584f5a34432.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add ``wait_fibonacci`` strategy that applies Fibonacci backoff. + This is a stateful wait strategy where wait times follow the Fibonacci + sequence (1, 2, 3, 5, 8, 13, ...) up to an optional maximum. diff --git a/tenacity/__init__.py b/tenacity/__init__.py index 282e6dae..654b39db 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -75,6 +75,7 @@ wait_exception, wait_exponential, wait_exponential_jitter, + wait_fibonacci, wait_fixed, wait_incrementing, wait_none, @@ -797,6 +798,7 @@ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]: "wait_exception", "wait_exponential", "wait_exponential_jitter", + "wait_fibonacci", "wait_fixed", "wait_full_jitter", "wait_incrementing", diff --git a/tenacity/wait.py b/tenacity/wait.py index c7a430eb..16ab1c9d 100644 --- a/tenacity/wait.py +++ b/tenacity/wait.py @@ -207,6 +207,51 @@ def __call__(self, retry_state: "RetryCallState") -> float: return max(max(0, self.min), min(result, self.max)) +class wait_fibonacci(wait_base): + """Wait strategy that applies a Fibonacci backoff. + + Fibonacci backoff is a compromise between linear and exponential backoff. + It increases the wait time more slowly than a pure exponential strategy, + making it effective for scenarios where a resource might be unavailable + for a moderate duration and you want to minimize latency without + hammering the server. + + .. note:: + This strategy is stateful. The wait intervals increase with each + subsequent retry performed by this specific instance. + + Example:: + + # Wait times: 1s, 2s, 3s, 5s, 8s, 13s... up to 60s + wait_fibonacci(max=60) + """ + + def __init__( + self, + max: _utils.time_unit_type = _utils.MAX_WAIT, + ) -> None: + """Initialize the wait strategy. + + :param max: The maximum number of seconds to wait. + """ + self.max = _utils.to_seconds(max) + self._serie = [0, 1] + + def compute_next_value(self) -> int: + """Compute the next value in the Fibonacci sequence.""" + next_value = self._serie[-1] + self._serie[-2] + self._serie.append(next_value) + return next_value + + def __call__(self, retry_state: "RetryCallState") -> float: + """Return the next wait time from the Fibonacci sequence.""" + try: + result = self.compute_next_value() + except OverflowError: + return self.max + return min(result, self.max) + + class wait_random_exponential(wait_exponential): """Random wait with exponentially widening window. diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 6a397392..4f4a086e 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -618,6 +618,55 @@ def test_wait_exponential_jitter(self) -> None: fn = tenacity.wait_exponential_jitter() fn(make_retry_state(0, 0)) + def test_wait_fibonacci(self) -> None: + # wait_fibonacci is purely stateful: it ignores retry_state and + # advances its internal Fibonacci sequence on each call. + fn = tenacity.wait_fibonacci() + expected = [1, 2, 3, 5, 8, 13, 21, 34] + for i, exp in enumerate(expected, 1): + self.assertEqual(fn(make_retry_state(i, 0)), exp) + + def test_wait_fibonacci_with_max_wait(self) -> None: + for max_ in (10, datetime.timedelta(seconds=10)): + with self.subTest(): + fn = tenacity.wait_fibonacci(max=max_) + # 1, 2, 3, 5, 8, 10, 10, 10 + self.assertEqual(fn(make_retry_state(1, 0)), 1) + self.assertEqual(fn(make_retry_state(2, 0)), 2) + self.assertEqual(fn(make_retry_state(3, 0)), 3) + self.assertEqual(fn(make_retry_state(4, 0)), 5) + self.assertEqual(fn(make_retry_state(5, 0)), 8) + self.assertEqual(fn(make_retry_state(6, 0)), 10) + self.assertEqual(fn(make_retry_state(7, 0)), 10) + self.assertEqual(fn(make_retry_state(8, 0)), 10) + + def test_wait_fibonacci_multiple_invocations(self) -> None: + sleep_intervals: list[float] = [] + r = Retrying( + sleep=sleep_intervals.append, + wait=tenacity.wait_fibonacci(max=60), + stop=tenacity.stop_after_attempt(6), + retry=tenacity.retry_if_result(lambda x: x == 1), + ) + + @r.wraps + def always_return_1() -> int: + return 1 + + self.assertRaises(tenacity.RetryError, always_return_1) + self.assertEqual(sleep_intervals, [1, 2, 3, 5, 8]) + sleep_intervals[:] = [] + + # Second invocation: wait_fibonacci is stateful, sequence continues. + # The first run consumed 6 Fibonacci values (5 sleeps + 1 extra wait + # call on the final attempt), so the second run picks up at position 7. + self.assertRaises(tenacity.RetryError, always_return_1) + self.assertEqual(sleep_intervals, [21, 34, 55, 60, 60]) + + def test_wait_fibonacci_default_args(self) -> None: + fn = tenacity.wait_fibonacci() + fn(make_retry_state(0, 0)) + def test_wait_exponential_jitter_min(self) -> None: fn = tenacity.wait_exponential_jitter(initial=1, max=60, jitter=1, min=5) for _ in range(1000):