Skip to content

Commit bb7d1cb

Browse files
committed
Improve devbox polling: eliminate client-side sleep, enable HTTP/2
Replace the client-side busy-poll loop (async_poll_until) for await_running with a new retry_server_poll_until that passes the remaining time budget to the server's wait_for_status endpoint as timeout_seconds. This eliminates a 30-second asyncio.sleep() between each long-poll attempt that was causing devbox startup times to cluster at 40s and 80s instead of the actual 3-5s provisioning time. Enable HTTP/2 by default on AsyncHttpxClient so that concurrent long-poll requests multiplex over fewer TCP connections instead of requiring one connection per in-flight request. For the Trajectory test, this dramatically improved the results with 494 concurrent devboxes: Before: p50=40.5s, p90=48.1s, max=80.9s After: p50=4.4s, p90=9.7s, max=20.6s
1 parent 98a746a commit bb7d1cb

4 files changed

Lines changed: 65 additions & 7 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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,48 @@ 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[[], 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 or sync callable that returns the object to check. This takes should
74+
take one argument, which is the remaing time to poll.q
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+
start_time = time.time()
87+
last_result: Union[T, None] = None
88+
89+
while True:
90+
elapsed = time.time() - start_time
91+
remaining_time = timeout_seconds - elapsed
92+
if remaining_time <= 0:
93+
raise PollingTimeout(f"Exceeded timeout of {timeout_seconds} seconds", last_result)
94+
95+
try:
96+
last_result = await retriever(remaining_time)
97+
except Exception as e:
98+
if on_error is not None:
99+
last_result = on_error(e)
100+
else:
101+
raise
102+
103+
if is_terminal(last_result):
104+
return last_result
105+

src/runloop_api_client/resources/devboxes/devboxes.py

Lines changed: 18 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,14 @@ 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) -> 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"],
2065+
"timeout_seconds": remaining_timeout_seconds },
20662066
cast_to=DevboxView,
20672067
)
20682068
except (APITimeoutError, APIStatusError) as error:
@@ -2077,7 +2077,19 @@ async def wait_for_devbox_status() -> DevboxView:
20772077
def is_done_booting(devbox: DevboxView) -> bool:
20782078
return devbox.status not in DEVBOX_BOOTING_STATES
20792079

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

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

0 commit comments

Comments
 (0)