From fdf2a9a3f78f02b8b36730e582c842ee7d253619 Mon Sep 17 00:00:00 2001 From: Bastien Chatelard Date: Tue, 14 Apr 2026 13:45:49 +0200 Subject: [PATCH] Expose poll_interval parameter and reduce the default polling interval --- docs/sandbox.md | 56 ++++++++++-------- examples/02_create_sandbox_with_timing.py | 1 + koyeb/sandbox/executor_client.py | 9 ++- koyeb/sandbox/sandbox.py | 72 ++++++++++++++++------- koyeb/sandbox/utils.py | 2 +- 5 files changed, 93 insertions(+), 47 deletions(-) diff --git a/docs/sandbox.md b/docs/sandbox.md index 186c186a..0efaa75c 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1004,7 +1004,8 @@ def create(cls, delete_after_delay: int = 0, delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, - enable_mesh: bool = None) -> Sandbox + enable_mesh: bool = None, + poll_interval: float = DEFAULT_POLL_INTERVAL) -> Sandbox ``` Create a new sandbox instance. @@ -1039,6 +1040,7 @@ Create a new sandbox instance. after this many seconds. - `app_id` - If provided, create the sandbox service in an existing app instead of creating a new one. - `enable_mesh` - Enable or disable mesh for this sandbox. Disabled by default +- `poll_interval` - Time between health checks in seconds when wait_ready is True (default: 0.5) **Returns**: @@ -1096,15 +1098,17 @@ Get a sandbox by service ID. ```python def wait_ready(timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL) -> bool + poll_interval: Optional[float] = None) -> bool ``` -Wait for sandbox to become ready with proper polling. +Wait for sandbox to become ready with exponential backoff polling. + +Starts polling at 0.1s intervals, doubling each time up to poll_interval. **Arguments**: - `timeout` - Maximum time to wait in seconds -- `poll_interval` - Time between health checks in seconds +- `poll_interval` - Maximum time between health checks in seconds (defaults to instance poll_interval) **Returns**: @@ -1117,19 +1121,18 @@ Wait for sandbox to become ready with proper polling. ```python def wait_tcp_proxy_ready(timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL) -> bool + poll_interval: Optional[float] = None) -> bool ``` Wait for TCP proxy to become ready and available. -Polls the deployment metadata until the TCP proxy information is available. -This is useful when enable_tcp_proxy=True was set during sandbox creation, -as the proxy information may not be immediately available. +Polls the deployment metadata with exponential backoff until the TCP proxy +information is available. Starts at 0.1s intervals, doubling up to poll_interval. **Arguments**: - `timeout` - Maximum time to wait in seconds -- `poll_interval` - Time between checks in seconds +- `poll_interval` - Maximum time between checks in seconds (defaults to instance poll_interval) **Returns**: @@ -1166,12 +1169,13 @@ def get_domain() -> Optional[str] Get the public domain of the sandbox. -Returns the domain name (e.g., "app-name-org.koyeb.app") without protocol or path. -To construct the URL, use: f"https://{sandbox.get_domain()}" +Returns the domain (e.g., "app-name-org.koyeb.app/r/routing_key/" or +"app-name-org.koyeb.app") without protocol. To get the full URL with protocol, +use sandbox._get_url() **Returns**: -- `Optional[str]` - The domain name or None if unavailable +- `Optional[str]` - The domain or None if unavailable @@ -1536,7 +1540,8 @@ async def create(cls, delete_after_delay: int = 0, delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, - enable_mesh: bool = False) -> AsyncSandbox + enable_mesh: bool = False, + poll_interval: float = DEFAULT_POLL_INTERVAL) -> AsyncSandbox ``` Create a new sandbox instance with async support. @@ -1573,6 +1578,7 @@ Create a new sandbox instance with async support. after this many seconds. - `app_id` - If provided, create the sandbox service in an existing app instead of creating a new one. - `enable_mesh` - Enable or disable mesh for this sandbox. Disabled by default +- `poll_interval` - Time between health checks in seconds when wait_ready is True (default: 0.5) **Returns**: @@ -1591,15 +1597,17 @@ Create a new sandbox instance with async support. ```python async def wait_ready(timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL) -> bool + poll_interval: Optional[float] = None) -> bool ``` -Wait for sandbox to become ready with proper async polling. +Wait for sandbox to become ready with exponential backoff async polling. + +Starts polling at 0.1s intervals, doubling each time up to poll_interval. **Arguments**: - `timeout` - Maximum time to wait in seconds -- `poll_interval` - Time between health checks in seconds +- `poll_interval` - Maximum time between health checks in seconds (defaults to instance poll_interval) **Returns**: @@ -1611,21 +1619,19 @@ Wait for sandbox to become ready with proper async polling. #### wait\_tcp\_proxy\_ready ```python -async def wait_tcp_proxy_ready( - timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL) -> bool +async def wait_tcp_proxy_ready(timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, + poll_interval: Optional[float] = None) -> bool ``` Wait for TCP proxy to become ready and available asynchronously. -Polls the deployment metadata until the TCP proxy information is available. -This is useful when enable_tcp_proxy=True was set during sandbox creation, -as the proxy information may not be immediately available. +Polls the deployment metadata with exponential backoff until the TCP proxy +information is available. Starts at 0.1s intervals, doubling up to poll_interval. **Arguments**: - `timeout` - Maximum time to wait in seconds -- `poll_interval` - Time between checks in seconds +- `poll_interval` - Maximum time between checks in seconds (defaults to instance poll_interval) **Returns**: @@ -2235,6 +2241,9 @@ def health() -> Dict[str, str] Check the health status of the server. +Uses a short timeout and no retries since callers (wait_ready) +already handle polling with backoff. + **Returns**: Dict with status information @@ -2243,6 +2252,7 @@ Check the health status of the server. **Raises**: - `requests.HTTPError` - If the health check fails +- `requests.Timeout` - If the health check times out diff --git a/examples/02_create_sandbox_with_timing.py b/examples/02_create_sandbox_with_timing.py index 492779c5..a39fc376 100644 --- a/examples/02_create_sandbox_with_timing.py +++ b/examples/02_create_sandbox_with_timing.py @@ -88,6 +88,7 @@ def main(run_long_tests=False): name=f"example-sandbox-timed-{suffix}", wait_ready=True, api_token=api_token, + # poll_interval=0.1, ) create_duration = time.time() - create_start tracker.record("Sandbox creation", create_duration, "setup") diff --git a/koyeb/sandbox/executor_client.py b/koyeb/sandbox/executor_client.py index 73a687f8..5525e39c 100644 --- a/koyeb/sandbox/executor_client.py +++ b/koyeb/sandbox/executor_client.py @@ -158,15 +158,20 @@ def health(self) -> Dict[str, str]: """ Check the health status of the server. + Uses a short timeout and no retries since callers (wait_ready) + already handle polling with backoff. + Returns: Dict with status information Raises: requests.HTTPError: If the health check fails + requests.Timeout: If the health check times out """ - response = self._request_with_retry( - "GET", f"{self.base_url}/health", timeout=self.timeout + response = self._session.get( + f"{self.base_url}/health", timeout=5 ) + response.raise_for_status() return response.json() def run( diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 16b2ab7e..7ce8773e 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -83,6 +83,7 @@ def __init__( name: Optional[str] = None, api_token: Optional[str] = None, sandbox_secret: Optional[str] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, ): self.sandbox_id = sandbox_id self.app_id = app_id @@ -90,6 +91,7 @@ def __init__( self.name = name self.api_token = api_token self.sandbox_secret = sandbox_secret + self.poll_interval = poll_interval self._created_at = time.time() self._sandbox_url: Optional[Tuple[str, Optional[str]]] = None self._domain: Optional[str] = None @@ -123,6 +125,7 @@ def create( delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, enable_mesh: bool = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, ) -> Sandbox: """ Create a new sandbox instance. @@ -156,6 +159,7 @@ def create( after this many seconds. app_id: If provided, create the sandbox service in an existing app instead of creating a new one. enable_mesh: Enable or disable mesh for this sandbox. Disabled by default + poll_interval: Time between health checks in seconds when wait_ready is True (default: 0.5) Returns: Sandbox: A new Sandbox instance @@ -200,6 +204,7 @@ def create( delete_after_inactivity_delay=delete_after_inactivity_delay, app_id=app_id, enable_mesh=enable_mesh, + poll_interval=poll_interval, ) if wait_ready: @@ -234,6 +239,7 @@ def _create_sync( delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, enable_mesh: bool = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, ) -> Sandbox: """ Synchronous creation method that returns creation parameters. @@ -302,6 +308,7 @@ def _create_sync( name=name, api_token=api_token, sandbox_secret=sandbox_secret, + poll_interval=poll_interval, ) @classmethod @@ -383,20 +390,25 @@ def get_from_id( def wait_ready( self, timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_interval: Optional[float] = None, ) -> bool: """ - Wait for sandbox to become ready with proper polling. + Wait for sandbox to become ready with exponential backoff polling. + + Starts polling at 0.1s intervals, doubling each time up to poll_interval. Args: timeout: Maximum time to wait in seconds - poll_interval: Time between health checks in seconds + poll_interval: Maximum time between health checks in seconds (defaults to instance poll_interval) Returns: bool: True if sandbox became ready, False if timeout """ + if poll_interval is None: + poll_interval = self.poll_interval start_time = time.time() sandbox_url = None + current_interval = 0.1 while time.time() - start_time < timeout: # Get sandbox URL on first iteration or if not yet retrieved @@ -404,7 +416,8 @@ def wait_ready( sandbox_url = self._get_sandbox_url() # If URL is not available yet, wait and retry if sandbox_url is None: - time.sleep(poll_interval) + time.sleep(current_interval) + current_interval = min(current_interval * 2, poll_interval) continue is_healthy = self.is_healthy() @@ -412,37 +425,41 @@ def wait_ready( if is_healthy: return True - time.sleep(poll_interval) + time.sleep(current_interval) + current_interval = min(current_interval * 2, poll_interval) return False def wait_tcp_proxy_ready( self, timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_interval: Optional[float] = None, ) -> bool: """ Wait for TCP proxy to become ready and available. - Polls the deployment metadata until the TCP proxy information is available. - This is useful when enable_tcp_proxy=True was set during sandbox creation, - as the proxy information may not be immediately available. + Polls the deployment metadata with exponential backoff until the TCP proxy + information is available. Starts at 0.1s intervals, doubling up to poll_interval. Args: timeout: Maximum time to wait in seconds - poll_interval: Time between checks in seconds + poll_interval: Maximum time between checks in seconds (defaults to instance poll_interval) Returns: bool: True if TCP proxy became ready, False if timeout """ + if poll_interval is None: + poll_interval = self.poll_interval start_time = time.time() + current_interval = 0.1 while time.time() - start_time < timeout: tcp_proxy_info = self.get_tcp_proxy_info() if tcp_proxy_info is not None: return True - time.sleep(poll_interval) + time.sleep(current_interval) + current_interval = min(current_interval * 2, poll_interval) return False @@ -1050,6 +1067,7 @@ async def create( delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, enable_mesh: bool = False, + poll_interval: float = DEFAULT_POLL_INTERVAL, ) -> AsyncSandbox: """ Create a new sandbox instance with async support. @@ -1085,6 +1103,7 @@ async def create( after this many seconds. app_id: If provided, create the sandbox service in an existing app instead of creating a new one. enable_mesh: Enable or disable mesh for this sandbox. Disabled by default + poll_interval: Time between health checks in seconds when wait_ready is True (default: 0.5) Returns: AsyncSandbox: A new AsyncSandbox instance @@ -1122,6 +1141,7 @@ async def create( delete_after_inactivity_delay=delete_after_inactivity_delay, app_id=app_id, enable_mesh=enable_mesh, + poll_interval=poll_interval, ), ) @@ -1133,6 +1153,7 @@ async def create( name=sync_result.name, api_token=sync_result.api_token, sandbox_secret=sync_result.sandbox_secret, + poll_interval=poll_interval, ) sandbox._created_at = sync_result._created_at @@ -1150,19 +1171,24 @@ async def create( async def wait_ready( self, timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_interval: Optional[float] = None, ) -> bool: """ - Wait for sandbox to become ready with proper async polling. + Wait for sandbox to become ready with exponential backoff async polling. + + Starts polling at 0.1s intervals, doubling each time up to poll_interval. Args: timeout: Maximum time to wait in seconds - poll_interval: Time between health checks in seconds + poll_interval: Maximum time between health checks in seconds (defaults to instance poll_interval) Returns: bool: True if sandbox became ready, False if timeout """ + if poll_interval is None: + poll_interval = self.poll_interval start_time = time.time() + current_interval = 0.1 while time.time() - start_time < timeout: loop = asyncio.get_running_loop() @@ -1171,30 +1197,33 @@ async def wait_ready( if is_healthy: return True - await asyncio.sleep(poll_interval) + await asyncio.sleep(current_interval) + current_interval = min(current_interval * 2, poll_interval) return False async def wait_tcp_proxy_ready( self, timeout: int = DEFAULT_INSTANCE_WAIT_TIMEOUT, - poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_interval: Optional[float] = None, ) -> bool: """ Wait for TCP proxy to become ready and available asynchronously. - Polls the deployment metadata until the TCP proxy information is available. - This is useful when enable_tcp_proxy=True was set during sandbox creation, - as the proxy information may not be immediately available. + Polls the deployment metadata with exponential backoff until the TCP proxy + information is available. Starts at 0.1s intervals, doubling up to poll_interval. Args: timeout: Maximum time to wait in seconds - poll_interval: Time between checks in seconds + poll_interval: Maximum time between checks in seconds (defaults to instance poll_interval) Returns: bool: True if TCP proxy became ready, False if timeout """ + if poll_interval is None: + poll_interval = self.poll_interval start_time = time.time() + current_interval = 0.1 while time.time() - start_time < timeout: loop = asyncio.get_running_loop() @@ -1204,7 +1233,8 @@ async def wait_tcp_proxy_ready( if tcp_proxy_info is not None: return True - await asyncio.sleep(poll_interval) + await asyncio.sleep(current_interval) + current_interval = min(current_interval * 2, poll_interval) return False diff --git a/koyeb/sandbox/utils.py b/koyeb/sandbox/utils.py index ba556539..dd1d46b5 100644 --- a/koyeb/sandbox/utils.py +++ b/koyeb/sandbox/utils.py @@ -41,7 +41,7 @@ MIN_PORT = 1 MAX_PORT = 65535 DEFAULT_INSTANCE_WAIT_TIMEOUT = 60 # seconds -DEFAULT_POLL_INTERVAL = 2.0 # seconds +DEFAULT_POLL_INTERVAL = 0.5 # seconds DEFAULT_COMMAND_TIMEOUT = 30 # seconds DEFAULT_HTTP_TIMEOUT = 30 # seconds for HTTP requests