From 89713c5b945cbfcd1b13107acecf535baeba927e Mon Sep 17 00:00:00 2001 From: Lalit Gupta Date: Wed, 7 Oct 2026 17:20:26 +0530 Subject: [PATCH] fix: preserve sandbox failure details and clarify polling timeouts --- README.md | 6 +++ tests/test_sandbox.py | 95 +++++++++++++++++++++++++++++++++++++++++++ videodb/sandbox.py | 18 ++++++-- 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 tests/test_sandbox.py diff --git a/README.md b/README.md index d10f4a2..730f01b 100644 --- a/README.md +++ b/README.md @@ -692,6 +692,12 @@ sandbox.stop(grace=True) sandbox.wait_for_stop(timeout=300, interval=5) ``` +If `wait_for_ready()` raises `RequestTimeoutError`, startup may still be in +progress. Call `refresh()` or `wait_for_ready()` again on the same sandbox; +there is no need to create another one. A terminal failure raises +`InvalidRequestError` with the server's error detail when available. That detail +is also available as `sandbox.error` after retrieving or refreshing the sandbox. + ### Generative Media Generate images, audio, and videos using AI: diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..b81983f --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,95 @@ +from unittest.mock import Mock, patch + +import pytest + +from videodb._constants import ApiPath, SandboxStatus +from videodb.exceptions import InvalidRequestError, RequestTimeoutError +from videodb.sandbox import Sandbox + + +@pytest.mark.parametrize("error_fields", [{}, {"error": None}, {"error": "Startup failed"}]) +def test_constructor_preserves_optional_server_error(error_fields): + sandbox = Sandbox(Mock(), sandbox_id="bx_test", **error_fields) + + assert sandbox.error == error_fields.get("error") + + +def test_refresh_preserves_missing_error_and_clears_explicit_null(): + connection = Mock() + connection.get.side_effect = [ + {"status": SandboxStatus.failed, "error": "Startup failed"}, + {"status": SandboxStatus.failed}, + {"error": None}, + ] + sandbox = Sandbox(connection, sandbox_id="bx_test") + + assert sandbox.refresh() is sandbox + assert sandbox.error == "Startup failed" + sandbox.refresh() + assert sandbox.error == "Startup failed" + sandbox.refresh() + assert sandbox.error is None + + +@pytest.mark.parametrize("status", [SandboxStatus.failed, SandboxStatus.stopped]) +def test_terminal_wait_includes_server_error(status): + connection = Mock() + connection.get.return_value = { + "status": status, + "error": "We could not start your sandbox. Please contact support.", + } + sandbox = Sandbox(connection, sandbox_id="bx_test") + + with pytest.raises(InvalidRequestError) as caught: + sandbox.wait_for_ready(timeout=0) + + assert f"Sandbox bx_test entered terminal state: {status}" in str(caught.value) + assert sandbox.error in str(caught.value) + connection.get.assert_called_once_with(path=f"{ApiPath.sandbox}/bx_test") + connection.post.assert_not_called() + + +@pytest.mark.parametrize("error_fields", [{}, {"error": None}, {"error": ""}]) +def test_terminal_wait_without_error_keeps_existing_message(error_fields): + connection = Mock() + connection.get.return_value = {"status": SandboxStatus.failed, **error_fields} + sandbox = Sandbox(connection, sandbox_id="bx_test") + + with pytest.raises(InvalidRequestError) as caught: + sandbox.wait_for_ready(timeout=0) + + assert str(caught.value).strip() == "Sandbox bx_test entered terminal state: failed" + + +@pytest.mark.parametrize("status", [SandboxStatus.active, SandboxStatus.alert]) +def test_ready_wait_returns_same_sandbox(status): + connection = Mock() + connection.get.return_value = {"status": status} + sandbox = Sandbox(connection, sandbox_id="bx_test") + + assert sandbox.wait_for_ready(timeout=0) is sandbox + + +def test_timed_out_wait_can_resume_same_sandbox_without_creating_or_stopping(): + connection = Mock() + connection.get.side_effect = [ + {"sandbox_id": "bx_test", "status": "provisioning"}, + {"sandbox_id": "bx_test", "status": SandboxStatus.active}, + ] + sandbox = Sandbox(connection, sandbox_id="bx_test") + + with patch("videodb.sandbox.time.time", side_effect=[100, 110, 120]): + with pytest.raises(RequestTimeoutError) as caught: + sandbox.wait_for_ready(timeout=10) + assert sandbox.status == "provisioning" + assert "Sandbox bx_test not ready within 10s" in str(caught.value) + assert "may still be in progress" in str(caught.value) + assert "same sandbox ID" in str(caught.value) + + assert sandbox.wait_for_ready(timeout=10) is sandbox + + assert sandbox.id == "bx_test" + assert connection.get.call_count == 2 + for call in connection.get.call_args_list: + assert call.kwargs == {"path": f"{ApiPath.sandbox}/bx_test"} + connection.post.assert_not_called() diff --git a/videodb/sandbox.py b/videodb/sandbox.py index 8b3fabf..34a10fe 100644 --- a/videodb/sandbox.py +++ b/videodb/sandbox.py @@ -25,6 +25,7 @@ def __init__( models=None, region=None, expires_at=None, + error=None, **kwargs, ): self._connection = _connection @@ -39,6 +40,7 @@ def __init__( self.models = models or [] self.region = region self.expires_at = expires_at + self.error = error def __repr__(self): return f"Sandbox(id={self.id}, tier={self.tier}, status={self.status}, name={self.name})" @@ -57,6 +59,7 @@ def _update(self, data): self.models = data.get("models", self.models) self.region = data.get("region", self.region) self.expires_at = data.get("expires_at", self.expires_at) + self.error = data.get("error", self.error) def refresh(self): """Fetch latest sandbox state from the server.""" @@ -67,6 +70,10 @@ def refresh(self): def wait_for_ready(self, timeout=300, interval=5): """Poll until the sandbox is active. + A timeout ends this wait only; startup may continue. Refresh or wait + again on this same sandbox to check its status. Terminal failures + include the server's error detail when available in ``self.error``. + :param int timeout: Maximum seconds to wait (default 300) :param int interval: Seconds between polls (default 5) :return: self @@ -79,12 +86,15 @@ def wait_for_ready(self, timeout=300, interval=5): if self.status in READY_STATUSES: return self if self.status in TERMINAL_STATUSES: - raise InvalidRequestError( - f"Sandbox {self.id} entered terminal state: {self.status}" - ) + message = f"Sandbox {self.id} entered terminal state: {self.status}" + if self.error: + message += f". {self.error}" + raise InvalidRequestError(message) if time.time() >= deadline: raise RequestTimeoutError( - f"Sandbox {self.id} not ready within {timeout}s" + f"Sandbox {self.id} not ready within {timeout}s. " + "Startup may still be in progress. Check its status or " + "continue waiting with the same sandbox ID." ) time.sleep(interval)