From 69099ac7f1eb630096685c1b476f1eefb1e4c492 Mon Sep 17 00:00:00 2001 From: Pierre Collignon Date: Thu, 27 Aug 2026 11:20:53 +0200 Subject: [PATCH] Raise SandboxError when the sandbox URL is unavailable _get_client / _get_async_client unpacked _get_sandbox_url() with no None-guard. For a gone sandbox the metadata/domain lookups swallow the NotFoundException and return None, so _get_sandbox_url() returns None and the unpack raised a raw `TypeError: cannot unpack non-iterable NoneType object` instead of the SandboxError the docstring promises. Guard the None case in both the sync and async client getters and raise SandboxError, so callers can detect a gone sandbox via the typed error. --- koyeb/sandbox/sandbox.py | 34 ++++++++++++++++------------ koyeb/sandbox/test_sandbox_client.py | 33 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 koyeb/sandbox/test_sandbox_client.py diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index b858e260..c3035b3c 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -1073,19 +1073,23 @@ def _get_sandbox_url(self) -> Optional[Tuple[str, Optional[str]]]: self._sandbox_url = (f"https://{domain}/koyeb-sandbox", None) return self._sandbox_url - def _get_conn_info(self) -> Optional[ConnectionInfo]: + def _get_conn_info(self) -> ConnectionInfo: """ Internal method to get the parameters needed to connect to the sandbox. - Caches the info after first retrieval. Returns: - Optional[ConnectionInfo]: the information needed to connect to the sandbox - """ - sandbox_url, routing_key = self._get_sandbox_url() - if sandbox_url: - return ConnectionInfo(sandbox_url, routing_key, self.sandbox_secret) + ConnectionInfo: the information needed to connect to the sandbox - return None + Raises: + SandboxError: If the sandbox URL is not available. + """ + url = self._get_sandbox_url() + if url is None: + raise SandboxError( + "Sandbox URL is not available (the sandbox may no longer exist)" + ) + sandbox_url, routing_key = url + return ConnectionInfo(sandbox_url, routing_key, self.sandbox_secret) def _get_client(self) -> "SandboxClient": # type: ignore[name-defined] """ @@ -1098,9 +1102,7 @@ def _get_client(self) -> "SandboxClient": # type: ignore[name-defined] SandboxError: If sandbox URL or secret is not available """ if self._client is None: - sandbox_url, routing_key = self._get_sandbox_url() - conn_info = ConnectionInfo(sandbox_url, routing_key, self.sandbox_secret) - self._client = create_sandbox_client(conn_info) + self._client = create_sandbox_client(self._get_conn_info()) return self._client def _check_response_error(self, response: Dict, operation: str) -> None: @@ -1517,13 +1519,15 @@ def __init__(self, *args, **kwargs): self._async_client = None def _get_async_client(self) -> "AsyncSandboxClient": - """Get or create AsyncSandboxClient instance.""" + """Get or create AsyncSandboxClient instance. + + Raises: + SandboxError: If the sandbox URL is not available. + """ if self._async_client is None: from .utils import create_async_sandbox_client - sandbox_url, routing_key = self._get_sandbox_url() - conn_info = ConnectionInfo(sandbox_url, routing_key, self.sandbox_secret) - self._async_client = create_async_sandbox_client(conn_info) + self._async_client = create_async_sandbox_client(self._get_conn_info()) return self._async_client @classmethod diff --git a/koyeb/sandbox/test_sandbox_client.py b/koyeb/sandbox/test_sandbox_client.py new file mode 100644 index 00000000..ade170e9 --- /dev/null +++ b/koyeb/sandbox/test_sandbox_client.py @@ -0,0 +1,33 @@ +import unittest +from unittest.mock import patch + +from koyeb.sandbox.sandbox import AsyncSandbox, Sandbox +from koyeb.sandbox.utils import SandboxError + + +class TestGetClientWhenUrlUnavailable(unittest.TestCase): + """A gone sandbox makes _get_sandbox_url() return None (the metadata/domain + lookups swallow NotFound and return None). _get_client/_get_async_client must + raise SandboxError in that case, as their docstring promises, rather than + letting a raw ``TypeError: cannot unpack non-iterable NoneType object`` escape. + """ + + def test_get_client_raises_sandbox_error(self): + sb = Sandbox.__new__(Sandbox) + sb._client = None + sb.sandbox_secret = None + with patch.object(Sandbox, "_get_sandbox_url", return_value=None): + with self.assertRaises(SandboxError): + sb._get_client() + + def test_get_async_client_raises_sandbox_error(self): + sb = AsyncSandbox.__new__(AsyncSandbox) + sb._async_client = None + sb.sandbox_secret = None + with patch.object(AsyncSandbox, "_get_sandbox_url", return_value=None): + with self.assertRaises(SandboxError): + sb._get_async_client() + + +if __name__ == "__main__": + unittest.main()