Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
95 changes: 95 additions & 0 deletions tests/test_sandbox.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 14 additions & 4 deletions videodb/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(
models=None,
region=None,
expires_at=None,
error=None,
**kwargs,
):
self._connection = _connection
Expand All @@ -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})"
Expand All @@ -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."""
Expand All @@ -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
Expand All @@ -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)

Expand Down
Loading