From 8761988a05060fc9192c0399574cc68b2d473f78 Mon Sep 17 00:00:00 2001 From: Bastien Chatelard Date: Thu, 11 Jun 2026 10:02:45 +0200 Subject: [PATCH 1/3] Improve caching of the deployment id This should avoid extra queries to service endpoint. --- koyeb/sandbox/sandbox.py | 61 ++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 049516a6..09a66038 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -102,6 +102,7 @@ def __init__( self._domain: Optional[str] = None self._url: Optional[str] = None self._client = None + self._deployment_id: Optional[str] = None @property def id(self) -> str: @@ -429,6 +430,18 @@ def get_from_id( DeploymentStatus.ERRORING, } + def _resolve_deployment_id(self) -> Optional[str]: + """Resolve and cache the deployment ID for this sandbox's service.""" + if self._deployment_id is not None: + return self._deployment_id + clients = get_api_clients(self.api_token, self.host) + service_response = clients.services.get_service(self.service_id) + service = service_response.service + deployment_id = service.active_deployment_id or service.latest_deployment_id + if deployment_id: + self._deployment_id = deployment_id + return deployment_id + def _is_deployment_healthy(self) -> bool: """ Check if the sandbox deployment status is HEALTHY via the API. @@ -440,15 +453,11 @@ def _is_deployment_healthy(self) -> bool: SandboxDeploymentError: If the deployment has reached a terminal error state """ try: - clients = get_api_clients(self.api_token, self.host) - services_api = clients.services - deployments_api = clients.deployments - service_response = services_api.get_service(self.service_id) - service = service_response.service - deployment_id = service.active_deployment_id or service.latest_deployment_id + deployment_id = self._resolve_deployment_id() if not deployment_id: return False - deployment_response = deployments_api.get_deployment(deployment_id) + clients = get_api_clients(self.api_token, self.host) + deployment_response = clients.deployments.get_deployment(deployment_id) status = deployment_response.deployment.status if status in self._DEPLOYMENT_ERROR_STATUSES: raise SandboxDeploymentError( @@ -497,9 +506,9 @@ def wait_ready( current_interval = min(current_interval * 2, poll_interval) continue - is_healthy = self.is_healthy() - - if is_healthy: + # Deployment is already confirmed healthy above, skip redundant + # _is_deployment_healthy() check and go straight to executor health + if self._check_executor_health(): return True time.sleep(current_interval) @@ -552,14 +561,14 @@ def _get_url_and_header_from_metadata(self) -> Optional[Tuple[str, str]]: try: from koyeb.api.exceptions import ApiException, NotFoundException + deployment_id = self._resolve_deployment_id() + if not deployment_id: + return None + from .utils import get_api_clients clients = get_api_clients(self.api_token, self.host) - services_api = clients.services - deployments_api = clients.deployments - service_response = services_api.get_service(self.service_id) - service = service_response.service - deployment = deployments_api.get_deployment(service.active_deployment_id or service.latest_deployment_id) + deployment = clients.deployments.get_deployment(deployment_id) metadata = deployment.deployment.metadata if metadata and metadata.sandbox: return metadata.sandbox.public_url, metadata.sandbox.routing_key @@ -750,18 +759,12 @@ def _check_response_error(self, response: Dict, operation: str) -> None: error_msg = response.get("error", "Unknown error") raise SandboxError(f"Failed to {operation}: {error_msg}") - def is_healthy(self) -> bool: - """Check if sandbox is healthy and ready for operations""" - # Check deployment status first to avoid sending traffic to a non-ready sandbox - if not self._is_deployment_healthy(): - return False - + def _check_executor_health(self) -> bool: + """Check if the sandbox executor is responsive. Assumes deployment is already healthy.""" sandbox_url, header = self._get_sandbox_url() if not sandbox_url or not self.sandbox_secret: return False - # Check executor health directly - this is what matters for operations - # If executor is healthy, the sandbox is usable (will wake up service if needed) try: from .executor_client import SandboxClient @@ -774,6 +777,14 @@ def is_healthy(self) -> bool: except Exception: return False + def is_healthy(self) -> bool: + """Check if sandbox is healthy and ready for operations""" + # Check deployment status first to avoid sending traffic to a non-ready sandbox + if not self._is_deployment_healthy(): + return False + + return self._check_executor_health() + @property def filesystem(self) -> "SandboxFilesystem": """Get filesystem operations interface""" @@ -1316,7 +1327,9 @@ async def wait_ready( current_interval = min(current_interval * 2, poll_interval) continue - is_healthy = await loop.run_in_executor(None, super().is_healthy) + # Deployment is already confirmed healthy above, skip redundant + # _is_deployment_healthy() check and go straight to executor health + is_healthy = await loop.run_in_executor(None, super()._check_executor_health) if is_healthy: return True From 4c59ce4ef49789a0402fa853bcbfd0e39d87f912 Mon Sep 17 00:00:00 2001 From: Bastien Chatelard Date: Thu, 11 Jun 2026 14:06:45 +0200 Subject: [PATCH 2/3] Improve caching of sandbox informations --- koyeb/sandbox/sandbox.py | 74 ++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 09a66038..0fe29aea 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -395,27 +395,27 @@ def get_from_id( sandbox_name = service.name - # Get deployment to extract sandbox_secret from env vars + # Get deployment to extract sandbox_secret and metadata deployment_id = service.active_deployment_id or service.latest_deployment_id sandbox_secret = None + sandbox_metadata = None if deployment_id: try: deployment_response = deployments_api.get_deployment(id=deployment_id) - if ( - deployment_response.deployment - and deployment_response.deployment.definition - and deployment_response.deployment.definition.env - ): + deployment = deployment_response.deployment + if deployment and deployment.definition and deployment.definition.env: # Find SANDBOX_SECRET in env vars - for env_var in deployment_response.deployment.definition.env: + for env_var in deployment.definition.env: if env_var.key == "SANDBOX_SECRET": sandbox_secret = env_var.value break + if deployment and deployment.metadata: + sandbox_metadata = deployment.metadata except Exception as e: logger.debug(f"Could not get deployment {deployment_id}: {e}") - return cls( + sandbox = cls( sandbox_id=service.id, app_id=service.app_id, service_id=service.id, @@ -424,6 +424,29 @@ def get_from_id( sandbox_secret=sandbox_secret, host=host, ) + if deployment_id: + sandbox._deployment_id = deployment_id + + # Pre-cache sandbox URL from deployment metadata or app domain + if sandbox_metadata and sandbox_metadata.sandbox: + sandbox._sandbox_url = ( + f"{sandbox_metadata.sandbox.public_url}/koyeb-sandbox", + sandbox_metadata.sandbox.routing_key, + ) + else: + # Fallback: resolve domain from app (we already have app_id) + try: + app_response = clients.apps.get_app(service.app_id) + app = app_response.app + if hasattr(app, "domains") and app.domains: + sandbox._sandbox_url = ( + f"https://{app.domains[0].name}/koyeb-sandbox", + None, + ) + except Exception: + pass + + return sandbox _DEPLOYMENT_ERROR_STATUSES = { DeploymentStatus.ERROR, @@ -445,6 +468,8 @@ def _resolve_deployment_id(self) -> Optional[str]: def _is_deployment_healthy(self) -> bool: """ Check if the sandbox deployment status is HEALTHY via the API. + When the deployment becomes healthy, also caches the sandbox URL + from deployment metadata if available. Returns: bool: True if the deployment status is HEALTHY, False otherwise @@ -458,13 +483,23 @@ def _is_deployment_healthy(self) -> bool: return False clients = get_api_clients(self.api_token, self.host) deployment_response = clients.deployments.get_deployment(deployment_id) - status = deployment_response.deployment.status + deployment = deployment_response.deployment + status = deployment.status if status in self._DEPLOYMENT_ERROR_STATUSES: raise SandboxDeploymentError( f"Sandbox '{self.name}' deployment reached status {status.value}. " f"The sandbox will not become ready." ) - return status == DeploymentStatus.HEALTHY + is_healthy = status == DeploymentStatus.HEALTHY + # Cache sandbox URL from metadata when deployment is healthy + if is_healthy and self._sandbox_url is None: + metadata = deployment.metadata + if metadata and metadata.sandbox: + self._sandbox_url = ( + f"{metadata.sandbox.public_url}/koyeb-sandbox", + metadata.sandbox.routing_key, + ) + return is_healthy except SandboxDeploymentError: raise except Exception as e: @@ -589,20 +624,17 @@ def _get_domain(self) -> Optional[str]: try: from koyeb.api.exceptions import ApiException, NotFoundException + if not self.app_id: + return None + from .utils import get_api_clients clients = get_api_clients(self.api_token, self.host) - apps_api = clients.apps - services_api = clients.services - service_response = services_api.get_service(self.service_id) - service = service_response.service - - if service.app_id: - app_response = apps_api.get_app(service.app_id) - app = app_response.app - if hasattr(app, "domains") and app.domains: - # Use the first public domain - return app.domains[0].name + app_response = clients.apps.get_app(self.app_id) + app = app_response.app + if hasattr(app, "domains") and app.domains: + # Use the first public domain + return app.domains[0].name return None except (NotFoundException, ApiException, Exception): return None From ebd6c732ba0032722171ce6a25aed429aa56a624 Mon Sep 17 00:00:00 2001 From: Bastien Chatelard Date: Thu, 11 Jun 2026 14:11:12 +0200 Subject: [PATCH 3/3] Improve caching for http clients --- koyeb/sandbox/exec.py | 8 ++------ koyeb/sandbox/filesystem.py | 8 ++------ koyeb/sandbox/sandbox.py | 34 +++++++++++++++++++--------------- koyeb/sandbox/utils.py | 16 ++++++++++++++-- 4 files changed, 37 insertions(+), 29 deletions(-) diff --git a/koyeb/sandbox/exec.py b/koyeb/sandbox/exec.py index 4b0f6aaf..17457c56 100644 --- a/koyeb/sandbox/exec.py +++ b/koyeb/sandbox/exec.py @@ -69,14 +69,10 @@ class SandboxExecutor: def __init__(self, sandbox: Sandbox) -> None: self.sandbox = sandbox - self._client = None def _get_client(self) -> SandboxClient: - """Get or create SandboxClient instance""" - if self._client is None: - conn_info = self.sandbox._get_conn_info() - self._client = create_sandbox_client(conn_info) - return self._client + """Get or create SandboxClient instance, shared with the sandbox""" + return self.sandbox._get_client() def __call__( self, diff --git a/koyeb/sandbox/filesystem.py b/koyeb/sandbox/filesystem.py index b4cf0fa6..1029789e 100644 --- a/koyeb/sandbox/filesystem.py +++ b/koyeb/sandbox/filesystem.py @@ -56,15 +56,11 @@ class SandboxFilesystem: def __init__(self, sandbox: Sandbox) -> None: self.sandbox = sandbox - self._client = None self._executor = None def _get_client(self) -> SandboxClient: - """Get or create SandboxClient instance""" - if self._client is None: - conn_info = self.sandbox._get_conn_info() - self._client = create_sandbox_client(conn_info) - return self._client + """Get or create SandboxClient instance, shared with the sandbox""" + return self.sandbox._get_client() def _get_executor(self) -> "SandboxExecutor": """Get or create SandboxExecutor instance""" diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 0fe29aea..851c8885 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -103,6 +103,8 @@ def __init__( self._url: Optional[str] = None self._client = None self._deployment_id: Optional[str] = None + self._executor = None + self._filesystem = None @property def id(self) -> str: @@ -793,14 +795,8 @@ def _check_response_error(self, response: Dict, operation: str) -> None: def _check_executor_health(self) -> bool: """Check if the sandbox executor is responsive. Assumes deployment is already healthy.""" - sandbox_url, header = self._get_sandbox_url() - if not sandbox_url or not self.sandbox_secret: - return False - try: - from .executor_client import SandboxClient - - client = SandboxClient(ConnectionInfo(sandbox_url, header, self.sandbox_secret)) + client = self._get_client() health_response = client.health() if isinstance(health_response, dict): status = health_response.get("status", "").lower() @@ -820,16 +816,20 @@ def is_healthy(self) -> bool: @property def filesystem(self) -> "SandboxFilesystem": """Get filesystem operations interface""" - from .filesystem import SandboxFilesystem + if self._filesystem is None: + from .filesystem import SandboxFilesystem - return SandboxFilesystem(self) + self._filesystem = SandboxFilesystem(self) + return self._filesystem @property def exec(self) -> "SandboxExecutor": """Get command execution interface""" - from .exec import SandboxExecutor + if self._executor is None: + from .exec import SandboxExecutor - return SandboxExecutor(self) + self._executor = SandboxExecutor(self) + return self._executor def expose_port(self, port: int) -> ExposedPort: """ @@ -1420,16 +1420,20 @@ async def is_healthy(self) -> bool: @property def exec(self) -> "AsyncSandboxExecutor": """Get async command execution interface""" - from .exec import AsyncSandboxExecutor + if self._executor is None: + from .exec import AsyncSandboxExecutor - return AsyncSandboxExecutor(self) + self._executor = AsyncSandboxExecutor(self) + return self._executor @property def filesystem(self) -> "AsyncSandboxFilesystem": """Get filesystem operations interface""" - from .filesystem import AsyncSandboxFilesystem + if self._filesystem is None: + from .filesystem import AsyncSandboxFilesystem - return AsyncSandboxFilesystem(self) + self._filesystem = AsyncSandboxFilesystem(self) + return self._filesystem @async_wrapper("expose_port") async def expose_port(self, port: int) -> ExposedPort: diff --git a/koyeb/sandbox/utils.py b/koyeb/sandbox/utils.py index 6449511f..ff87bf06 100644 --- a/koyeb/sandbox/utils.py +++ b/koyeb/sandbox/utils.py @@ -9,7 +9,7 @@ import os import shlex from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple from koyeb.api import ApiClient, Configuration from koyeb.api.api import ( @@ -104,12 +104,17 @@ class ApiClients: secrets: SecretsApi +_api_clients_cache: Dict[Tuple[str, str], ApiClients] = {} + + def get_api_clients( api_token: Optional[str] = None, host: Optional[str] = None ) -> ApiClients: """ Get configured API clients for Koyeb operations. + Caches clients by (token, host) to reuse the underlying HTTP connection pool. + Args: api_token: Koyeb API token. If not provided, will try to get from KOYEB_API_TOKEN env var host: Koyeb API host URL. If not provided, will try to get from KOYEB_API_HOST env var (defaults to https://app.koyeb.com) @@ -129,12 +134,17 @@ def get_api_clients( api_host = os.getenv("KOYEB_API_HOST", host) if not api_host: api_host = "https://app.koyeb.com" + cache_key = (token, api_host) + + if cache_key in _api_clients_cache: + return _api_clients_cache[cache_key] + configuration = Configuration(host=api_host) configuration.api_key["Bearer"] = token configuration.api_key_prefix["Bearer"] = "Bearer" api_client = ApiClient(configuration) - return ApiClients( + clients = ApiClients( apps=AppsApi(api_client), services=ServicesApi(api_client), instances=InstancesApi(api_client), @@ -142,6 +152,8 @@ def get_api_clients( deployments=DeploymentsApi(api_client), secrets=SecretsApi(api_client), ) + _api_clients_cache[cache_key] = clients + return clients def build_env_vars(env: Optional[Dict[str, Any]]) -> List[DeploymentEnv]: