Skip to content

Commit 3fc564d

Browse files
authored
Improve devbox polling: eliminate client-side sleep, enable HTTP/2 (#793)
1 parent 98a746a commit 3fc564d

5 files changed

Lines changed: 101 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ authors = [
99
]
1010

1111
dependencies = [
12-
"httpx>=0.23.0, <1",
12+
"httpx[http2]>=0.23.0, <1",
1313
"pydantic>=2.0, <3",
1414
"typing-extensions>=4.14, <5",
1515
"anyio>=3.5.0, <5",

src/runloop_api_client/_base_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,7 @@ def __init__(self, **kwargs: Any) -> None:
13751375
kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
13761376
kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS)
13771377
kwargs.setdefault("follow_redirects", True)
1378+
kwargs.setdefault("http2", True)
13781379
super().__init__(**kwargs)
13791380

13801381

src/runloop_api_client/lib/polling_async.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,46 @@ async def async_poll_until(
5858
raise PollingTimeout(f"Exceeded timeout of {config.timeout_seconds} seconds", last_result)
5959

6060
await asyncio.sleep(config.interval_seconds)
61+
62+
63+
async def retry_server_poll_until(
64+
retriever: Callable[[float], Awaitable[T]],
65+
is_terminal: Callable[[T], bool],
66+
timeout_seconds: float = 30.0,
67+
on_error: Optional[Callable[[Exception], T]] = None,
68+
) -> T:
69+
"""
70+
Retry a server-side long-poll until a condition is met or max timeout is reached.
71+
72+
Args:
73+
retriever: Async callable that takes the remaining timeout (seconds) and
74+
returns the object to check.
75+
is_terminal: Callable that returns True when polling should stop
76+
timeout_seconds: Total time to wait. Must be > 0
77+
on_error: Optional error handler that can return a value to continue polling
78+
or re-raise the exception to stop polling
79+
80+
Returns:
81+
The final state of the polled object
82+
83+
Raises:
84+
PollingTimeout: When max attempts or timeout is reached
85+
"""
86+
last_result: Union[T, None] = None
87+
start_time = time.time()
88+
89+
while True:
90+
remaining_time = timeout_seconds - (time.time() - start_time)
91+
if remaining_time <= 0:
92+
raise PollingTimeout(f"Exceeded timeout of {timeout_seconds} seconds", last_result)
93+
94+
try:
95+
last_result = await retriever(remaining_time)
96+
except Exception as e:
97+
if on_error is not None:
98+
last_result = on_error(e)
99+
else:
100+
raise
101+
102+
if is_terminal(last_result):
103+
return last_result

src/runloop_api_client/resources/devboxes/devboxes.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
DiskSnapshotsResourceWithStreamingResponse,
8383
AsyncDiskSnapshotsResourceWithStreamingResponse,
8484
)
85-
from ...lib.polling_async import async_poll_until
85+
from ...lib.polling_async import async_poll_until, retry_server_poll_until
8686
from ...types.devbox_view import DevboxView
8787
from ...types.tunnel_view import TunnelView
8888
from ...types.shared_params.mount import Mount
@@ -2042,11 +2042,10 @@ async def await_running(
20422042
20432043
Args:
20442044
id: The ID of the devbox to wait for
2045-
config: Optional polling configuration
2045+
polling_config: Optional polling configuration
20462046
extra_headers: Send extra headers
20472047
extra_query: Add additional query parameters to the request
20482048
extra_body: Add additional JSON properties to the request
2049-
timeout: Override the client-level default timeout for this request, in seconds
20502049
20512050
Returns:
20522051
The devbox in running state
@@ -2056,13 +2055,13 @@ async def await_running(
20562055
RunloopError: If devbox enters a non-running terminal state
20572056
"""
20582057

2059-
async def wait_for_devbox_status() -> DevboxView:
2058+
async def wait_for_devbox_status(remaining_timeout_seconds: float) -> DevboxView:
20602059
# This wait_for_status endpoint polls the devbox status for 10 seconds until it reaches either running or failure.
20612060
# If it's neither, it will throw an error.
20622061
try:
20632062
return await self._post(
20642063
f"/v1/devboxes/{id}/wait_for_status",
2065-
body={"statuses": ["running", "failure", "shutdown"]},
2064+
body={"statuses": ["running", "failure", "shutdown"], "timeout_seconds": remaining_timeout_seconds},
20662065
cast_to=DevboxView,
20672066
)
20682067
except (APITimeoutError, APIStatusError) as error:
@@ -2077,7 +2076,19 @@ async def wait_for_devbox_status() -> DevboxView:
20772076
def is_done_booting(devbox: DevboxView) -> bool:
20782077
return devbox.status not in DEVBOX_BOOTING_STATES
20792078

2080-
devbox = await async_poll_until(wait_for_devbox_status, is_done_booting, polling_config)
2079+
# calculate the timeout to use. The PollingConfig doesn't
2080+
# match the semantics for server-side polling well, so we
2081+
# instead convert interval*attempts to a total time, and take
2082+
# the minimum total.
2083+
config = polling_config
2084+
if not config:
2085+
config = PollingConfig() # use defaults
2086+
2087+
timeout = config.interval_seconds * config.max_attempts
2088+
if config.timeout_seconds is not None and config.timeout_seconds > 0:
2089+
timeout = min(config.timeout_seconds, timeout)
2090+
2091+
devbox = await retry_server_poll_until(wait_for_devbox_status, is_done_booting, timeout)
20812092

20822093
if devbox.status != "running":
20832094
raise RunloopError(f"Devbox entered non-running terminal state: {devbox.status}")

uv.lock

Lines changed: 39 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)