Skip to content

Commit 3228d76

Browse files
authored
Use higher precision time source in retry decorator/tests and avoid flaky failures (#12839)
I've replaced time.monotonic() with time.perf_counter() in the retry utility and test suite since it's effectively monotonic[^1] while providing much higher resolution (esp. on Windows). In addition, to avoid flaky failures (originally on Windows) I've added a margin of error to the timed tests. I've also outright disabled the timed tests on Windows as further testing revealed that they were simply too unreliable to be useful (I once observed a deviation of 30%). [^1]: It's not guaranteed to be monotonic in the Python docs, but it is _indeed_ monotonic on all platforms we care about.
1 parent 6b54c19 commit 3228d76

File tree

2 files changed

+21
-11
lines changed

2 files changed

+21
-11
lines changed

src/pip/_internal/utils/retry.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import functools
2-
from time import monotonic, sleep
2+
from time import perf_counter, sleep
33
from typing import Callable, TypeVar
44

55
from pip._vendor.typing_extensions import ParamSpec
@@ -26,12 +26,14 @@ def wrapper(func: Callable[P, T]) -> Callable[P, T]:
2626

2727
@functools.wraps(func)
2828
def retry_wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
29-
start_time = monotonic()
29+
# The performance counter is monotonic on all platforms we care
30+
# about and has much better resolution than time.monotonic().
31+
start_time = perf_counter()
3032
while True:
3133
try:
3234
return func(*args, **kwargs)
3335
except Exception:
34-
if monotonic() - start_time > stop_after_delay:
36+
if perf_counter() - start_time > stop_after_delay:
3537
raise
3638
sleep(wait)
3739

tests/unit/test_utils_retry.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import random
2-
from time import monotonic, sleep
2+
import sys
3+
from time import perf_counter, sleep
34
from typing import List, NoReturn, Tuple, Type
45
from unittest.mock import Mock
56

@@ -65,39 +66,46 @@ def create_timestamped_callable(sleep_per_call: float = 0) -> Tuple[Mock, List[f
6566
timestamps = []
6667

6768
def _raise_error() -> NoReturn:
68-
timestamps.append(monotonic())
69+
timestamps.append(perf_counter())
6970
if sleep_per_call:
7071
sleep(sleep_per_call)
7172
raise RuntimeError
7273

7374
return Mock(wraps=_raise_error), timestamps
7475

7576

76-
# Use multiple of 15ms as Windows' sleep is only accurate to 15ms.
77+
@pytest.mark.skipif(
78+
sys.platform == "win32", reason="Too flaky on Windows due to poor timer resolution"
79+
)
7780
@pytest.mark.parametrize("wait_duration", [0.015, 0.045, 0.15])
7881
def test_retry_wait(wait_duration: float) -> None:
7982
function, timestamps = create_timestamped_callable()
8083
# Only the first retry will be scheduled before the time limit is exceeded.
8184
wrapped = retry(wait=wait_duration, stop_after_delay=0.01)(function)
82-
start_time = monotonic()
85+
start_time = perf_counter()
8386
with pytest.raises(RuntimeError):
8487
wrapped()
8588
assert len(timestamps) == 2
86-
assert timestamps[1] - start_time >= wait_duration
89+
# Add a margin of 10% to permit for unavoidable variation.
90+
assert timestamps[1] - start_time >= (wait_duration * 0.9)
8791

8892

93+
@pytest.mark.skipif(
94+
sys.platform == "win32", reason="Too flaky on Windows due to poor timer resolution"
95+
)
8996
@pytest.mark.parametrize(
90-
"call_duration, max_allowed_calls", [(0.01, 10), (0.04, 3), (0.15, 1)]
97+
"call_duration, max_allowed_calls", [(0.01, 11), (0.04, 3), (0.15, 1)]
9198
)
9299
def test_retry_time_limit(call_duration: float, max_allowed_calls: int) -> None:
93100
function, timestamps = create_timestamped_callable(sleep_per_call=call_duration)
94101
wrapped = retry(wait=0, stop_after_delay=0.1)(function)
95102

96-
start_time = monotonic()
103+
start_time = perf_counter()
97104
with pytest.raises(RuntimeError):
98105
wrapped()
99106
assert len(timestamps) <= max_allowed_calls
100-
assert all(t - start_time <= 0.1 for t in timestamps)
107+
# Add a margin of 10% to permit for unavoidable variation.
108+
assert all(t - start_time <= (0.1 * 1.1) for t in timestamps)
101109

102110

103111
def test_retry_method() -> None:

0 commit comments

Comments
 (0)