Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/runloop_api_client/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 21 additions & 4 deletions src/runloop_api_client/lib/wait_for_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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()
Expand All @@ -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:
Expand Down
26 changes: 23 additions & 3 deletions src/runloop_api_client/resources/devboxes/devboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/runloop_api_client/resources/devboxes/executions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down