Skip to content

Commit 1037642

Browse files
gautam-rlclaude
andcommitted
chore(devbox): cap forwarded long-poll timeout and add client read margin
Long-poll helpers forwarded timeout_seconds=remaining (often well above what the server honors) and left the client read timeout at the global 30s default. Cap the forwarded timeout_seconds at the server clamp (30s status, 25s exec) and set a per-request read timeout of server_hold + 5s so the client is never the party that aborts a long-poll. Defensive hygiene: this does not by itself eliminate held-stream cancels. Those are server-initiated (the h2 stream idle timeout racing the long-poll clamp) and are fixed server-side. Pairs with #817. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b0cc07c commit 1037642

4 files changed

Lines changed: 57 additions & 8 deletions

File tree

src/runloop_api_client/_constants.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
INITIAL_RETRY_DELAY = 1.0
1414
MAX_RETRY_DELAY = 60.0
1515

16+
# Long-poll endpoints tell the server how long to hold the connection and expect a
17+
# graceful 408 when that hold elapses. The per-request client read timeout must
18+
# exceed the server hold so the server returns 408 first instead of the client
19+
# aborting the stream (RST_STREAM) and surfacing a connection error.
20+
LONG_POLL_CLIENT_BUFFER_SECONDS = 5.0
21+
22+
# Authoritative server-side long-poll hold clamps, mirrored from the mux controllers.
23+
STATUS_LONG_POLL_SERVER_MAX_SECONDS = 30.0
24+
EXEC_LONG_POLL_SERVER_MAX_SECONDS = 25.0
25+
1626
# Maximum allowed size (in bytes) for individual entries in `file_mounts` when creating Blueprints
1727
# NOTE: Empirically, ~131,000 is the maximum command length after
1828
# base64 encoding; 98,250 is the pre-encoded limit that stays within that bound.

src/runloop_api_client/lib/wait_for_status.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
import time
1414
from typing import List, Type, TypeVar, Callable, Optional, Awaitable
1515

16+
import httpx
17+
1618
from .polling import PollingConfig, PollingTimeout
19+
from .._constants import LONG_POLL_CLIENT_BUFFER_SECONDS, STATUS_LONG_POLL_SERVER_MAX_SECONDS
1720
from .._exceptions import APIStatusError, APIConnectionError
1821

1922
T = TypeVar("T")
@@ -27,6 +30,7 @@ def wait_for_status(
2730
placeholder: Callable[[], T],
2831
is_terminal: Callable[[T], bool],
2932
polling_config: Optional[PollingConfig] = None,
33+
server_max_timeout_seconds: float = STATUS_LONG_POLL_SERVER_MAX_SECONDS,
3034
) -> T:
3135
"""Sync long-poll for a status change, retrying until *is_terminal* or timeout."""
3236
config = polling_config or PollingConfig()
@@ -42,12 +46,18 @@ def wait_for_status(
4246
if remaining <= 0:
4347
raise PollingTimeout(f"Exceeded timeout of {timeout} seconds", last_result)
4448

49+
# Cap the server hold at what the server honors, and give the client read
50+
# timeout a buffer beyond it so the server returns 408 first.
51+
server_timeout = min(remaining, server_max_timeout_seconds)
4552
try:
4653
last_result = post_fn(
4754
path,
48-
body={"statuses": statuses, "timeout_seconds": remaining},
55+
body={"statuses": statuses, "timeout_seconds": server_timeout},
4956
cast_to=cast_to,
50-
options={"max_retries": 0},
57+
options={
58+
"max_retries": 0,
59+
"timeout": httpx.Timeout(server_timeout + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0),
60+
},
5161
)
5262
except (APIConnectionError, APIStatusError) as error:
5363
if isinstance(error, APIConnectionError) or error.response.status_code == 408:
@@ -67,6 +77,7 @@ async def async_wait_for_status(
6777
placeholder: Callable[[], T],
6878
is_terminal: Callable[[T], bool],
6979
polling_config: Optional[PollingConfig] = None,
80+
server_max_timeout_seconds: float = STATUS_LONG_POLL_SERVER_MAX_SECONDS,
7081
) -> T:
7182
"""Async long-poll for a status change, retrying until *is_terminal* or timeout."""
7283
config = polling_config or PollingConfig()
@@ -82,12 +93,18 @@ async def async_wait_for_status(
8293
if remaining <= 0:
8394
raise PollingTimeout(f"Exceeded timeout of {timeout} seconds", last_result)
8495

96+
# Cap the server hold at what the server honors, and give the client read
97+
# timeout a buffer beyond it so the server returns 408 first.
98+
server_timeout = min(remaining, server_max_timeout_seconds)
8599
try:
86100
last_result = await post_fn(
87101
path,
88-
body={"statuses": statuses, "timeout_seconds": remaining},
102+
body={"statuses": statuses, "timeout_seconds": server_timeout},
89103
cast_to=cast_to,
90-
options={"max_retries": 0},
104+
options={
105+
"max_retries": 0,
106+
"timeout": httpx.Timeout(server_timeout + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0),
107+
},
91108
)
92109
except (APIConnectionError, APIStatusError) as error:
93110
if isinstance(error, APIConnectionError) or error.response.status_code == 408:

src/runloop_api_client/resources/devboxes/devboxes.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@
6464
async_to_custom_raw_response_wrapper,
6565
async_to_custom_streamed_response_wrapper,
6666
)
67-
from ..._constants import DEFAULT_TIMEOUT
67+
from ..._constants import (
68+
DEFAULT_TIMEOUT,
69+
LONG_POLL_CLIENT_BUFFER_SECONDS,
70+
EXEC_LONG_POLL_SERVER_MAX_SECONDS,
71+
)
6872
from ...pagination import (
6973
SyncDevboxesCursorIDPage,
7074
AsyncDevboxesCursorIDPage,
@@ -967,7 +971,15 @@ def is_done(result: DevboxAsyncExecutionDetailView) -> bool:
967971
return result.status == "completed"
968972

969973
return poll_until(
970-
lambda: self.wait_for_command(execution.execution_id, devbox_id=devbox_id, statuses=["completed"]),
974+
# Client read timeout must exceed the server long-poll hold so the server
975+
# returns 408 first instead of the client aborting the stream.
976+
lambda: self.wait_for_command(
977+
execution.execution_id,
978+
devbox_id=devbox_id,
979+
statuses=["completed"],
980+
timeout_seconds=int(EXEC_LONG_POLL_SERVER_MAX_SECONDS),
981+
timeout=httpx.Timeout(EXEC_LONG_POLL_SERVER_MAX_SECONDS + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0),
982+
),
971983
is_done,
972984
polling_config,
973985
handle_timeout_error,
@@ -2637,7 +2649,15 @@ def is_done(result: DevboxAsyncExecutionDetailView) -> bool:
26372649
return result.status == "completed"
26382650

26392651
return await async_poll_until(
2640-
lambda: self.wait_for_command(execution.execution_id, devbox_id=devbox_id, statuses=["completed"]),
2652+
# Client read timeout must exceed the server long-poll hold so the server
2653+
# returns 408 first instead of the client aborting the stream.
2654+
lambda: self.wait_for_command(
2655+
execution.execution_id,
2656+
devbox_id=devbox_id,
2657+
statuses=["completed"],
2658+
timeout_seconds=int(EXEC_LONG_POLL_SERVER_MAX_SECONDS),
2659+
timeout=httpx.Timeout(EXEC_LONG_POLL_SERVER_MAX_SECONDS + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0),
2660+
),
26412661
is_done,
26422662
polling_config,
26432663
handle_timeout_error,

src/runloop_api_client/resources/devboxes/executions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
async_to_raw_response_wrapper,
1919
async_to_streamed_response_wrapper,
2020
)
21-
from ..._constants import DEFAULT_TIMEOUT, RAW_RESPONSE_HEADER
21+
from ..._constants import DEFAULT_TIMEOUT, RAW_RESPONSE_HEADER, EXEC_LONG_POLL_SERVER_MAX_SECONDS
2222
from ..._streaming import Stream, AsyncStream, ReconnectingStream, AsyncReconnectingStream
2323
from ...lib.polling import PollingConfig
2424
from ..._base_client import make_request_options
@@ -149,6 +149,7 @@ def is_done(execution: DevboxAsyncExecutionDetailView) -> bool:
149149
lambda: placeholder_execution_detail_view(devbox_id, execution_id),
150150
is_done,
151151
polling_config,
152+
EXEC_LONG_POLL_SERVER_MAX_SECONDS,
152153
)
153154

154155
def execute_async(
@@ -680,6 +681,7 @@ def is_done(execution: DevboxAsyncExecutionDetailView) -> bool:
680681
lambda: placeholder_execution_detail_view(devbox_id, execution_id),
681682
is_done,
682683
polling_config,
684+
EXEC_LONG_POLL_SERVER_MAX_SECONDS,
683685
)
684686

685687
async def execute_async(

0 commit comments

Comments
 (0)