From 10376425000d21aebf34e45238a838411ff7d71d Mon Sep 17 00:00:00 2001 From: Gautam Raj <156491596+gautam-rl@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:41:23 -0700 Subject: [PATCH] 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 --- src/runloop_api_client/_constants.py | 10 +++++++ src/runloop_api_client/lib/wait_for_status.py | 25 +++++++++++++++--- .../resources/devboxes/devboxes.py | 26 ++++++++++++++++--- .../resources/devboxes/executions.py | 4 ++- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/runloop_api_client/_constants.py b/src/runloop_api_client/_constants.py index 88f944ce2..83506afe3 100644 --- a/src/runloop_api_client/_constants.py +++ b/src/runloop_api_client/_constants.py @@ -13,6 +13,16 @@ INITIAL_RETRY_DELAY = 1.0 MAX_RETRY_DELAY = 60.0 +# Long-poll endpoints tell the server how long to hold the connection and expect a +# graceful 408 when that hold elapses. The per-request client read timeout must +# exceed the server hold so the server returns 408 first instead of the client +# aborting the stream (RST_STREAM) and surfacing a connection error. +LONG_POLL_CLIENT_BUFFER_SECONDS = 5.0 + +# Authoritative server-side long-poll hold clamps, mirrored from the mux controllers. +STATUS_LONG_POLL_SERVER_MAX_SECONDS = 30.0 +EXEC_LONG_POLL_SERVER_MAX_SECONDS = 25.0 + # Maximum allowed size (in bytes) for individual entries in `file_mounts` when creating Blueprints # NOTE: Empirically, ~131,000 is the maximum command length after # base64 encoding; 98,250 is the pre-encoded limit that stays within that bound. diff --git a/src/runloop_api_client/lib/wait_for_status.py b/src/runloop_api_client/lib/wait_for_status.py index cb7bb9193..ab9f28bbf 100644 --- a/src/runloop_api_client/lib/wait_for_status.py +++ b/src/runloop_api_client/lib/wait_for_status.py @@ -13,7 +13,10 @@ import time from typing import List, Type, TypeVar, Callable, Optional, Awaitable +import httpx + from .polling import PollingConfig, PollingTimeout +from .._constants import LONG_POLL_CLIENT_BUFFER_SECONDS, STATUS_LONG_POLL_SERVER_MAX_SECONDS from .._exceptions import APIStatusError, APIConnectionError T = TypeVar("T") @@ -27,6 +30,7 @@ def wait_for_status( placeholder: Callable[[], T], is_terminal: Callable[[T], bool], polling_config: Optional[PollingConfig] = None, + server_max_timeout_seconds: float = STATUS_LONG_POLL_SERVER_MAX_SECONDS, ) -> T: """Sync long-poll for a status change, retrying until *is_terminal* or timeout.""" config = polling_config or PollingConfig() @@ -42,12 +46,18 @@ def wait_for_status( if remaining <= 0: raise PollingTimeout(f"Exceeded timeout of {timeout} seconds", last_result) + # Cap the server hold at what the server honors, and give the client read + # timeout a buffer beyond it so the server returns 408 first. + server_timeout = min(remaining, server_max_timeout_seconds) try: last_result = post_fn( path, - body={"statuses": statuses, "timeout_seconds": remaining}, + body={"statuses": statuses, "timeout_seconds": server_timeout}, cast_to=cast_to, - options={"max_retries": 0}, + options={ + "max_retries": 0, + "timeout": httpx.Timeout(server_timeout + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0), + }, ) except (APIConnectionError, APIStatusError) as error: if isinstance(error, APIConnectionError) or error.response.status_code == 408: @@ -67,6 +77,7 @@ async def async_wait_for_status( placeholder: Callable[[], T], is_terminal: Callable[[T], bool], polling_config: Optional[PollingConfig] = None, + server_max_timeout_seconds: float = STATUS_LONG_POLL_SERVER_MAX_SECONDS, ) -> T: """Async long-poll for a status change, retrying until *is_terminal* or timeout.""" config = polling_config or PollingConfig() @@ -82,12 +93,18 @@ async def async_wait_for_status( if remaining <= 0: raise PollingTimeout(f"Exceeded timeout of {timeout} seconds", last_result) + # Cap the server hold at what the server honors, and give the client read + # timeout a buffer beyond it so the server returns 408 first. + server_timeout = min(remaining, server_max_timeout_seconds) try: last_result = await post_fn( path, - body={"statuses": statuses, "timeout_seconds": remaining}, + body={"statuses": statuses, "timeout_seconds": server_timeout}, cast_to=cast_to, - options={"max_retries": 0}, + options={ + "max_retries": 0, + "timeout": httpx.Timeout(server_timeout + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0), + }, ) except (APIConnectionError, APIStatusError) as error: if isinstance(error, APIConnectionError) or error.response.status_code == 408: diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index 9a9ab288a..fdbe2737f 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -64,7 +64,11 @@ async_to_custom_raw_response_wrapper, async_to_custom_streamed_response_wrapper, ) -from ..._constants import DEFAULT_TIMEOUT +from ..._constants import ( + DEFAULT_TIMEOUT, + LONG_POLL_CLIENT_BUFFER_SECONDS, + EXEC_LONG_POLL_SERVER_MAX_SECONDS, +) from ...pagination import ( SyncDevboxesCursorIDPage, AsyncDevboxesCursorIDPage, @@ -967,7 +971,15 @@ def is_done(result: DevboxAsyncExecutionDetailView) -> bool: return result.status == "completed" return poll_until( - lambda: self.wait_for_command(execution.execution_id, devbox_id=devbox_id, statuses=["completed"]), + # Client read timeout must exceed the server long-poll hold so the server + # returns 408 first instead of the client aborting the stream. + lambda: self.wait_for_command( + execution.execution_id, + devbox_id=devbox_id, + statuses=["completed"], + timeout_seconds=int(EXEC_LONG_POLL_SERVER_MAX_SECONDS), + timeout=httpx.Timeout(EXEC_LONG_POLL_SERVER_MAX_SECONDS + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0), + ), is_done, polling_config, handle_timeout_error, @@ -2637,7 +2649,15 @@ def is_done(result: DevboxAsyncExecutionDetailView) -> bool: return result.status == "completed" return await async_poll_until( - lambda: self.wait_for_command(execution.execution_id, devbox_id=devbox_id, statuses=["completed"]), + # Client read timeout must exceed the server long-poll hold so the server + # returns 408 first instead of the client aborting the stream. + lambda: self.wait_for_command( + execution.execution_id, + devbox_id=devbox_id, + statuses=["completed"], + timeout_seconds=int(EXEC_LONG_POLL_SERVER_MAX_SECONDS), + timeout=httpx.Timeout(EXEC_LONG_POLL_SERVER_MAX_SECONDS + LONG_POLL_CLIENT_BUFFER_SECONDS, connect=5.0), + ), is_done, polling_config, handle_timeout_error, diff --git a/src/runloop_api_client/resources/devboxes/executions.py b/src/runloop_api_client/resources/devboxes/executions.py index e5bfd7ed8..4b0e3d4c4 100755 --- a/src/runloop_api_client/resources/devboxes/executions.py +++ b/src/runloop_api_client/resources/devboxes/executions.py @@ -18,7 +18,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ..._constants import DEFAULT_TIMEOUT, RAW_RESPONSE_HEADER +from ..._constants import DEFAULT_TIMEOUT, RAW_RESPONSE_HEADER, EXEC_LONG_POLL_SERVER_MAX_SECONDS from ..._streaming import Stream, AsyncStream, ReconnectingStream, AsyncReconnectingStream from ...lib.polling import PollingConfig from ..._base_client import make_request_options @@ -149,6 +149,7 @@ def is_done(execution: DevboxAsyncExecutionDetailView) -> bool: lambda: placeholder_execution_detail_view(devbox_id, execution_id), is_done, polling_config, + EXEC_LONG_POLL_SERVER_MAX_SECONDS, ) def execute_async( @@ -680,6 +681,7 @@ def is_done(execution: DevboxAsyncExecutionDetailView) -> bool: lambda: placeholder_execution_detail_view(devbox_id, execution_id), is_done, polling_config, + EXEC_LONG_POLL_SERVER_MAX_SECONDS, ) async def execute_async(