Skip to content

Commit 59e186b

Browse files
fix: clamp retry delay to max_delay after jitter
retry_with_backoff applied max_delay before the jitter multiplier, so with jitter=0.1 the realized delay could reach max_delay * 1.1. Clamp a second time after jitter so max_delay is a hard ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9bb9aee commit 59e186b

2 files changed

Lines changed: 39 additions & 2 deletions

File tree

src/dqliteclient/retry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ async def retry_with_backoff[T](
4747
# Calculate delay with exponential backoff
4848
delay = min(base_delay * (2**attempt), max_delay)
4949

50-
# Add jitter
50+
# Add jitter, then re-clamp so max_delay stays a hard ceiling.
5151
if jitter > 0:
52-
delay = delay * (1 + random.uniform(-jitter, jitter))
52+
delay = min(delay * (1 + random.uniform(-jitter, jitter)), max_delay)
5353

5454
await asyncio.sleep(delay)
5555

tests/test_retry.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,40 @@ async def mock_sleep(delay: float) -> None:
103103
assert sleep_args[0] == pytest.approx(0.05)
104104
# Second delay: 0.1 * 2^1 = 0.2, capped to 0.05
105105
assert sleep_args[1] == pytest.approx(0.05)
106+
107+
async def test_jitter_does_not_exceed_max_delay(self) -> None:
108+
"""max_delay is a hard ceiling: even with jitter, realized delay must not exceed it."""
109+
from unittest.mock import patch
110+
111+
sleep_args: list[float] = []
112+
113+
async def always_fail() -> str:
114+
raise ValueError("fail")
115+
116+
original_sleep = asyncio.sleep
117+
118+
async def mock_sleep(delay: float) -> None:
119+
sleep_args.append(delay)
120+
await original_sleep(0)
121+
122+
# Force jitter to its positive endpoint so the multiplier is (1 + jitter)
123+
def max_jitter(_low: float, high: float) -> float:
124+
return high
125+
126+
with (
127+
patch("dqliteclient.retry.asyncio.sleep", side_effect=mock_sleep),
128+
patch("dqliteclient.retry.random.uniform", side_effect=max_jitter),
129+
pytest.raises(ValueError, match="fail"),
130+
):
131+
await retry_with_backoff(
132+
always_fail,
133+
max_attempts=10,
134+
base_delay=1.0,
135+
max_delay=2.0,
136+
jitter=0.1,
137+
)
138+
139+
# Several attempts will hit the cap; none should exceed max_delay.
140+
assert sleep_args, "expected at least one sleep"
141+
for d in sleep_args:
142+
assert d <= 2.0, f"delay {d} exceeded max_delay=2.0"

0 commit comments

Comments
 (0)