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
8 changes: 6 additions & 2 deletions src/xai_sdk/poll_timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ def __init__(
context: A description of what is being waited on (e.g. "waiting for document to be indexed").
Included in the TimeoutError message for easier debugging.
"""
self._start = time.time()
# A monotonic clock is required for duration accounting: time.time()
# is a wall clock, so NTP corrections, manual clock changes or VM
# suspend/restore can make a healthy poll time out immediately (or
# poll far beyond the caller's timeout).
self._start = time.monotonic()
self._timeout = timeout or datetime.timedelta(minutes=10)
self._interval = interval or datetime.timedelta(seconds=1)
self._context = context
Expand All @@ -38,7 +42,7 @@ def sleep_interval_or_raise(self) -> float:
Raises:
TimeoutError when the total polling time is used up.
"""
runtime = time.time() - self._start
runtime = time.monotonic() - self._start
if runtime > self._timeout.total_seconds():
message = f"Polling timed out after {runtime:.1f}s"
if self._context:
Expand Down
55 changes: 55 additions & 0 deletions tests/poll_timer_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""PollTimer must account elapsed time on a monotonic clock.

time.time() is a wall clock: NTP corrections, manual clock changes or VM
suspend/restore can move it, making a healthy poll time out immediately
(a forward adjustment) or poll far beyond the caller's timeout (a
backward adjustment). Regression for #203.
"""

import datetime
from unittest.mock import patch

from xai_sdk.poll_timer import PollTimer


def test_forward_wall_clock_adjustment_does_not_time_out():
# The wall clock jumps forward 9900s between construction and the first
# sleep computation; monotonic elapsed time is still ~0.5s.
with patch("xai_sdk.poll_timer.time.monotonic", side_effect=[100.0, 100.5]):
timer = PollTimer(
timeout=datetime.timedelta(seconds=10),
interval=datetime.timedelta(seconds=20),
)
interval = timer.sleep_interval_or_raise()

assert interval == 9.5


def test_backward_wall_clock_adjustment_does_not_extend_timeout():
# The wall clock jumps backward while 15s actually elapse: the poll must
# still time out instead of quietly polling beyond the caller's timeout.
with patch("xai_sdk.poll_timer.time.monotonic", side_effect=[10_000.0, 10_015.0]):
timer = PollTimer(
timeout=datetime.timedelta(seconds=10),
interval=datetime.timedelta(seconds=1),
)
try:
timer.sleep_interval_or_raise()
except TimeoutError as e:
assert "timed out after 15.0s" in str(e)
else:
raise AssertionError("expected TimeoutError")


def test_timeout_still_raises_on_real_elapsed_time():
with patch("xai_sdk.poll_timer.time.monotonic", side_effect=[100.0, 200.0]):
timer = PollTimer(
timeout=datetime.timedelta(seconds=10),
interval=datetime.timedelta(seconds=1),
)
try:
timer.sleep_interval_or_raise()
except TimeoutError as e:
assert "timed out after 100.0s" in str(e)
else:
raise AssertionError("expected TimeoutError")