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
17 changes: 17 additions & 0 deletions doc/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,23 @@ increasing jitter helps minimise collisions.
print("Randomly wait up to 2^x * 1 seconds between each retry until the range reaches 60 seconds, then randomly up to 60 seconds afterwards")
raise Exception

When reproducibility, a bounded worst-case concurrency peak, or the absence
of a strong RNG are required, ``wait_golden_jitter`` provides a deterministic
alternative. Assign a distinct ``seq_index`` to each waiter (host id, shard,
worker number) so that their delays are spread evenly across the backoff
window:

.. testcode::

@retry(wait=wait_golden_jitter(multiplier=1, max=60, seq_index=0))
def wait_golden():
print("Deterministic, reproducible jitter spread across waiters "
"by golden-ratio low discrepancy. Pass a distinct seq_index "
"per waiter (host id, shard, worker number).")
raise Exception

When waiters cannot supply distinct indices, prefer the random strategies.


Sometimes it's necessary to build a chain of backoffs.

Expand Down
2 changes: 2 additions & 0 deletions tenacity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
wait_exponential,
wait_exponential_jitter,
wait_fixed,
wait_golden_jitter,
wait_incrementing,
wait_none,
wait_random,
Expand Down Expand Up @@ -807,6 +808,7 @@ def wrap(f: t.Callable[P, R]) -> _RetryDecorated[P, R]:
"wait_exponential_jitter",
"wait_fixed",
"wait_full_jitter",
"wait_golden_jitter",
"wait_incrementing",
"wait_none",
"wait_random",
Expand Down
68 changes: 68 additions & 0 deletions tenacity/wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,71 @@ def __call__(self, retry_state: "RetryCallState") -> float:
except OverflowError:
result = self.max
return max(max(0, self.min), min(result, self.max))


# 1/phi = phi - 1: the most irrational number, giving optimal equidistribution
# of points across an interval (Weyl equidistribution / three-distance theorem).
_GOLDEN_CONJUGATE = 0.6180339887498949


class wait_golden_jitter(wait_base):
"""Wait strategy applying exponential backoff with *deterministic*
low-discrepancy jitter, derived from the golden ratio.

This is a deterministic alternative to :class:`wait_random_exponential`
(the "Full Jitter" algorithm). Instead of drawing a random delay, each
waiter is assigned a point from the golden-ratio low-discrepancy
sequence, indexed by ``seq_index``.

The golden ratio is the most irrational number, so its additive
recurrence ``(seq_index * (1/phi)) mod 1`` distributes points across the
backoff window more evenly than independent random draws, which clump by
chance. This yields:

* **Bounded worst-case spread**: the maximum concurrency peak across N
uncoordinated waiters is controlled, not luck-dependent.
* **Reproducibility**: identical runs produce identical schedules, which
simplifies testing and debugging (random jitter does not).
* **No RNG required**: useful where a per-waiter index (host id, shard,
task number) is available but a strong RNG is not (e.g. embedded/IoT).

The backoff window still expands exponentially per attempt, exactly like
:class:`wait_random_exponential`.

.. note::
When waiters cannot supply a distinct ``seq_index``, pass a stable
per-process integer (e.g. derived from hostname or worker id). If all
callers share the same index, this degenerates to a fixed schedule and
provides no desynchronization — supply distinct indices per waiter.

Example::

@retry(wait=wait_golden_jitter(multiplier=1, max=60, seq_index=worker_id))
def fetch():
...
"""

def __init__(
self,
multiplier: float = 1,
max: _utils.time_unit_type = _utils.MAX_WAIT,
exp_base: float = 2,
min: _utils.time_unit_type = 0,
seq_index: int = 0,
) -> None:
self.multiplier = multiplier
self.min = _utils.to_seconds(min)
self.max = _utils.to_seconds(max)
self.exp_base = exp_base
self.seq_index = seq_index
self.phase = (seq_index * _GOLDEN_CONJUGATE) % 1.0

def __call__(self, retry_state: "RetryCallState") -> float:
try:
exp = self.exp_base ** (retry_state.attempt_number - 1)
high = self.multiplier * exp
except OverflowError:
high = self.max
high = max(0.0, min(high, self.max))
jittered = self.phase * high
return max(self.min, min(jittered, self.max))
35 changes: 35 additions & 0 deletions tests/test_tenacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,41 @@ def test_wait_exponential_jitter_initial_and_multiplier_raises(self) -> None:
with self.assertRaises(ValueError):
tenacity.wait_exponential_jitter(initial=5, multiplier=10)

def test_wait_golden_jitter_deterministic_reproducible(self) -> None:
w1 = tenacity.wait_golden_jitter(multiplier=1, max=60, seq_index=42)
w2 = tenacity.wait_golden_jitter(multiplier=1, max=60, seq_index=42)
for attempt in range(1, 8):
rs = make_retry_state(attempt, 0)
self.assertEqual(w1(rs), w2(rs))

def test_wait_golden_jitter_respects_max(self) -> None:
w = tenacity.wait_golden_jitter(multiplier=1, max=10, seq_index=7)
for attempt in range(1, 20):
self.assertLessEqual(w(make_retry_state(attempt, 0)), 10)

def test_wait_golden_jitter_respects_min(self) -> None:
w = tenacity.wait_golden_jitter(multiplier=1, max=60, min=2, seq_index=3)
for attempt in range(1, 10):
self.assertGreaterEqual(w(make_retry_state(attempt, 0)), 2)

def test_wait_golden_jitter_within_exponential_window(self) -> None:
w = tenacity.wait_golden_jitter(multiplier=1, max=1e18, exp_base=2, seq_index=5)
for attempt in range(1, 10):
high = 1 * (2 ** (attempt - 1))
self.assertLessEqual(w(make_retry_state(attempt, 0)), high + 1e-9)

def test_wait_golden_jitter_distinct_indices_distinct_phases(self) -> None:
phases = {tenacity.wait_golden_jitter(seq_index=i).phase for i in range(50)}
self.assertEqual(len(phases), 50)

def test_wait_golden_jitter_low_discrepancy(self) -> None:
phases = sorted(
tenacity.wait_golden_jitter(seq_index=i).phase for i in range(20)
)
gaps = [phases[i + 1] - phases[i] for i in range(len(phases) - 1)]
# Three-distance theorem guarantees the largest gap is at most ~2x ideal (1/20).
self.assertLess(max(gaps), 0.11)

def test_wait_retry_state_attributes(self) -> None:
class ExtractCallState(Exception):
pass
Expand Down
Loading