From 5bddbf170da6ba9a5ccfbd2199a5118500489a8c Mon Sep 17 00:00:00 2001 From: yzxcj797 <1784931579@qq.com> Date: Sat, 22 Aug 2026 11:41:06 +0800 Subject: [PATCH] fix: account polling elapsed time on the monotonic clock PollTimer measured elapsed duration with time.time(), a wall clock. NTP corrections, manual clock changes or VM suspend/restore move it: a forward adjustment can make an otherwise healthy deferred chat, collection indexing or video-generation poll time out immediately, and a backward adjustment can extend polling beyond the caller's requested timeout. Both the sync and async polling paths share this helper. Use time.monotonic(), which cannot go backward, for the start timestamp and the elapsed-time computation. No public API change and no normal-case timing change. Fixes #203 --- src/xai_sdk/poll_timer.py | 8 ++++-- tests/poll_timer_test.py | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/poll_timer_test.py diff --git a/src/xai_sdk/poll_timer.py b/src/xai_sdk/poll_timer.py index 9e71c76..b36ed70 100644 --- a/src/xai_sdk/poll_timer.py +++ b/src/xai_sdk/poll_timer.py @@ -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 @@ -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: diff --git a/tests/poll_timer_test.py b/tests/poll_timer_test.py new file mode 100644 index 0000000..aeb3f76 --- /dev/null +++ b/tests/poll_timer_test.py @@ -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")