952 lines
35 KiB
Python

"""Main SandboxClient class for interacting with the sandbox server API."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Optional
import httpx
from langsmith import utils as ls_utils
from langsmith.sandbox._exceptions import (
ResourceCreationError,
ResourceNameConflictError,
ResourceNotFoundError,
ResourceTimeoutError,
SandboxAPIError,
)
from langsmith.sandbox._helpers import (
handle_client_http_error,
handle_sandbox_creation_error,
merge_headers,
validate_service_params,
validate_ttl,
)
from langsmith.sandbox._models import (
ResourceStatus,
ServiceURL,
Snapshot,
)
from langsmith.sandbox._sandbox import Sandbox
from langsmith.sandbox._transport import RetryTransport
def _get_default_api_endpoint() -> str:
"""Get the default sandbox API endpoint from environment.
Derives the endpoint from LANGSMITH_ENDPOINT (or LANGCHAIN_ENDPOINT).
"""
base = ls_utils.get_env_var("ENDPOINT", default="https://api.smith.langchain.com")
return f"{base.rstrip('/')}/v2/sandboxes"
def _get_default_api_key() -> Optional[str]:
"""Get the default API key from environment."""
return ls_utils.get_env_var("API_KEY")
RequestHeaders = Optional[Mapping[str, str]]
class SandboxClient:
"""Client for interacting with the Sandbox Server API.
This client provides a simple interface for managing sandboxes and snapshots.
Example:
# Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment
client = SandboxClient()
# Or with explicit configuration
client = SandboxClient(
api_endpoint="https://api.smith.langchain.com/v2/sandboxes",
api_key="your-api-key",
)
# Create a sandbox from a snapshot and run commands
with client.sandbox(snapshot_id="<snapshot-uuid>") as sandbox:
result = sandbox.run("python --version")
print(result.stdout)
"""
def __init__(
self,
*,
api_endpoint: Optional[str] = None,
timeout: float = 10.0,
api_key: Optional[str] = None,
max_retries: int = 3,
headers: Optional[RequestHeaders] = None,
):
"""Initialize the SandboxClient.
Args:
api_endpoint: Full URL of the sandbox API endpoint. If not provided,
derived from LANGSMITH_ENDPOINT environment variable.
timeout: Default HTTP timeout in seconds.
api_key: API key for authentication. If not provided, uses
LANGSMITH_API_KEY environment variable.
max_retries: Maximum number of retries for transient errors (502, 503,
504), rate limits (429), and connection failures. Set to 0
to disable retries. Default: 3.
"""
self._base_url = (api_endpoint or _get_default_api_endpoint()).rstrip("/")
resolved_api_key = api_key or _get_default_api_key()
self._api_key = resolved_api_key
client_headers: dict[str, str] = {}
if resolved_api_key:
client_headers["X-Api-Key"] = resolved_api_key
if headers:
client_headers = merge_headers(client_headers, headers)
transport = RetryTransport(max_retries=max_retries)
self._http = httpx.Client(
transport=transport, timeout=timeout, headers=client_headers
)
def _request_headers(self, headers: RequestHeaders) -> Optional[dict[str, str]]:
"""Merge default client headers with per-request overrides."""
if headers is None:
return None
return merge_headers(self._http.headers, headers)
def close(self) -> None:
"""Close the HTTP client."""
self._http.close()
def __del__(self) -> None:
"""Close the HTTP client on garbage collection."""
try:
if not self._http.is_closed:
self._http.close()
except Exception:
pass
def __enter__(self) -> SandboxClient:
"""Enter context manager."""
return self
def __exit__(
self,
exc_type: Optional[type],
exc_val: Optional[BaseException],
exc_tb: Optional[Any],
) -> None:
"""Exit context manager."""
self.close()
def __repr__(self) -> str:
"""Return a string representation of the instance.
Returns:
The string representation of the instance.
"""
return f"SandboxClient (API URL: {self._base_url})"
# ========================================================================
# Sandbox Operations
# ========================================================================
def sandbox(
self,
snapshot_id: Optional[str] = None,
*,
snapshot_name: Optional[str] = None,
name: Optional[str] = None,
timeout: int = 30,
idle_ttl_seconds: Optional[int] = None,
delete_after_stop_seconds: Optional[int] = None,
vcpus: Optional[int] = None,
mem_bytes: Optional[int] = None,
fs_capacity_bytes: Optional[int] = None,
proxy_config: Optional[dict[str, Any]] = None,
headers: RequestHeaders = None,
) -> Sandbox:
"""Create a sandbox and return a Sandbox instance.
This is the primary method for creating sandboxes. Use it as a
context manager for automatic cleanup:
with client.sandbox(snapshot_id="<uuid>") as sandbox:
result = sandbox.run("echo hello")
# Resolve by snapshot name instead of ID:
with client.sandbox(snapshot_name="my-snap") as sandbox:
result = sandbox.run("echo hello")
The sandbox is automatically deleted when exiting the context manager.
For sandboxes with manual lifecycle management, use create_sandbox().
Args:
snapshot_id: Snapshot ID to boot from. Mutually exclusive with
``snapshot_name``; exactly one must be provided.
snapshot_name: Snapshot name to boot from. Resolved server-side to a
snapshot owned by the caller's tenant. Mutually exclusive with
``snapshot_id``; exactly one must be provided.
name: Optional sandbox name (auto-generated if not provided).
timeout: Timeout in seconds when waiting for ready.
idle_ttl_seconds: Idle timeout in seconds. The launcher
automatically stops the sandbox after this duration of
inactivity. Must be a multiple of 60. ``0`` explicitly
disables the idle stop. When omitted (``None``), the server
applies a default of ``600`` seconds (10 minutes).
delete_after_stop_seconds: Seconds after the sandbox enters the
``stopped`` state before it (and its filesystem clone) are
permanently deleted. Must be a multiple of 60. ``0`` disables
stop-anchored deletion (manual cleanup required). When
omitted (``None``), the server applies its configured default.
vcpus: Number of vCPUs.
mem_bytes: Memory in bytes.
fs_capacity_bytes: Root filesystem capacity in bytes.
proxy_config: Per-sandbox proxy configuration forwarded to the
server as-is. Shape matches the backend `proxy_config` field:
``{"rules": [...], "no_proxy": [...], "access_control":
{"allow_list": [...]}}`` or ``{"access_control":
{"deny_list": [...]}}``. Use ``access_control.allow_list`` to
restrict outbound HTTPS to a set of host patterns (exact
domains, globs like ``*.example.com``, IPs, CIDRs, or
``~regex``).
Returns:
Sandbox instance.
Raises:
ResourceTimeoutError: If timeout waiting for sandbox to be ready.
ResourceCreationError: If sandbox creation fails.
SandboxClientError: For other errors.
ValueError: If TTL values are invalid, or if neither/both of
``snapshot_id`` and ``snapshot_name`` are provided.
"""
sb = self.create_sandbox(
snapshot_id,
snapshot_name=snapshot_name,
name=name,
timeout=timeout,
idle_ttl_seconds=idle_ttl_seconds,
delete_after_stop_seconds=delete_after_stop_seconds,
vcpus=vcpus,
mem_bytes=mem_bytes,
fs_capacity_bytes=fs_capacity_bytes,
proxy_config=proxy_config,
headers=headers,
)
sb._auto_delete = True
return sb
def create_sandbox(
self,
snapshot_id: Optional[str] = None,
*,
snapshot_name: Optional[str] = None,
name: Optional[str] = None,
timeout: int = 30,
wait_for_ready: bool = True,
idle_ttl_seconds: Optional[int] = None,
delete_after_stop_seconds: Optional[int] = None,
vcpus: Optional[int] = None,
mem_bytes: Optional[int] = None,
fs_capacity_bytes: Optional[int] = None,
proxy_config: Optional[dict[str, Any]] = None,
headers: RequestHeaders = None,
) -> Sandbox:
"""Create a new Sandbox.
The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup,
or use sandbox() for automatic cleanup with a context manager.
Args:
snapshot_id: Snapshot ID to boot from. Mutually exclusive with
``snapshot_name``; exactly one must be provided.
snapshot_name: Snapshot name to boot from. Resolved server-side to a
snapshot owned by the caller's tenant. Mutually exclusive with
``snapshot_id``; exactly one must be provided.
name: Optional sandbox name (auto-generated if not provided).
timeout: Timeout in seconds when waiting for ready (only used when
wait_for_ready=True).
wait_for_ready: If True (default), block until sandbox is ready.
If False, return immediately with status "provisioning". Use
get_sandbox_status() or wait_for_sandbox() to poll for readiness.
idle_ttl_seconds: Idle timeout in seconds. The launcher
automatically stops the sandbox after this duration of
inactivity. Must be a multiple of 60. ``0`` explicitly
disables the idle stop. When omitted (``None``), the server
applies a default of ``600`` seconds (10 minutes).
delete_after_stop_seconds: Seconds after the sandbox enters the
``stopped`` state before it (and its filesystem clone) are
permanently deleted. Must be a multiple of 60. ``0`` disables
stop-anchored deletion (manual cleanup required). When
omitted (``None``), the server applies its configured default.
vcpus: Number of vCPUs.
mem_bytes: Memory in bytes.
fs_capacity_bytes: Root filesystem capacity in bytes.
proxy_config: Per-sandbox proxy configuration forwarded to the
server as-is. Shape matches the backend `proxy_config` field:
``{"rules": [...], "no_proxy": [...], "access_control":
{"allow_list": [...]}}`` or ``{"access_control":
{"deny_list": [...]}}``. Use ``access_control.allow_list`` to
restrict outbound HTTPS to a set of host patterns (exact
domains, globs like ``*.example.com``, IPs, CIDRs, or
``~regex``).
Returns:
Created Sandbox. When wait_for_ready=False, the sandbox will have
status="provisioning" and cannot be used for operations until ready.
Raises:
ResourceTimeoutError: If timeout waiting for sandbox to be ready.
ResourceCreationError: If sandbox creation fails.
SandboxClientError: For other errors.
ValueError: If TTL values are invalid, or if neither/both of
``snapshot_id`` and ``snapshot_name`` are provided.
"""
if bool(snapshot_id) == bool(snapshot_name):
raise ValueError("Exactly one of snapshot_id or snapshot_name must be set")
validate_ttl(idle_ttl_seconds, "idle_ttl_seconds")
validate_ttl(delete_after_stop_seconds, "delete_after_stop_seconds")
url = f"{self._base_url}/boxes"
payload: dict[str, Any] = {
"wait_for_ready": wait_for_ready,
}
if snapshot_id:
payload["snapshot_id"] = snapshot_id
if snapshot_name:
payload["snapshot_name"] = snapshot_name
if wait_for_ready:
payload["timeout"] = timeout
if name:
payload["name"] = name
if idle_ttl_seconds is not None:
payload["idle_ttl_seconds"] = idle_ttl_seconds
if delete_after_stop_seconds is not None:
payload["delete_after_stop_seconds"] = delete_after_stop_seconds
if vcpus is not None:
payload["vcpus"] = vcpus
if mem_bytes is not None:
payload["mem_bytes"] = mem_bytes
if fs_capacity_bytes is not None:
payload["fs_capacity_bytes"] = fs_capacity_bytes
if proxy_config is not None:
payload["proxy_config"] = proxy_config
http_timeout = (timeout + 30) if wait_for_ready else 30
try:
response = self._http.post(
url,
json=payload,
timeout=http_timeout,
headers=self._request_headers(headers),
)
response.raise_for_status()
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
except httpx.HTTPStatusError as e:
handle_sandbox_creation_error(e)
raise # pragma: no cover
def get_sandbox(self, name: str, *, headers: RequestHeaders = None) -> Sandbox:
"""Get a Sandbox by name.
The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup.
Args:
name: Sandbox name.
Returns:
Sandbox.
Raises:
ResourceNotFoundError: If sandbox not found.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/boxes/{name}"
try:
response = self._http.get(url, headers=self._request_headers(headers))
response.raise_for_status()
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{name}' not found", resource_type="sandbox"
) from e
handle_client_http_error(e)
raise # pragma: no cover
def list_sandboxes(self, *, headers: RequestHeaders = None) -> list[Sandbox]:
"""List all Sandboxes.
Returns:
List of Sandboxes.
"""
url = f"{self._base_url}/boxes"
try:
response = self._http.get(url, headers=self._request_headers(headers))
response.raise_for_status()
data = response.json()
return [
Sandbox.from_dict(c, client=self, auto_delete=False)
for c in data.get("sandboxes", [])
]
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise SandboxAPIError(
f"API endpoint not found: {url}. "
f"Check that api_endpoint is correct."
) from e
handle_client_http_error(e)
raise # pragma: no cover
def update_sandbox(
self,
name: str,
*,
new_name: Optional[str] = None,
idle_ttl_seconds: Optional[int] = None,
delete_after_stop_seconds: Optional[int] = None,
headers: RequestHeaders = None,
) -> Sandbox:
"""Update a sandbox's properties.
Args:
name: Current sandbox name.
new_name: New display name.
idle_ttl_seconds: Idle timeout in seconds. Must be a multiple of
60. ``0`` disables idle-stop. ``None`` leaves the existing
value unchanged.
delete_after_stop_seconds: Seconds after entering ``stopped``
before deletion. Must be a multiple of 60. ``0`` disables
stop-anchored deletion. ``None`` leaves the existing value
unchanged.
Returns:
Updated Sandbox.
Raises:
ResourceNotFoundError: If sandbox not found.
ResourceNameConflictError: If new_name is already in use.
SandboxClientError: For other errors.
ValueError: If TTL values are invalid.
"""
validate_ttl(idle_ttl_seconds, "idle_ttl_seconds")
validate_ttl(delete_after_stop_seconds, "delete_after_stop_seconds")
url = f"{self._base_url}/boxes/{name}"
payload: dict[str, Any] = {}
if new_name is not None:
payload["name"] = new_name
if idle_ttl_seconds is not None:
payload["idle_ttl_seconds"] = idle_ttl_seconds
if delete_after_stop_seconds is not None:
payload["delete_after_stop_seconds"] = delete_after_stop_seconds
try:
response = self._http.patch(
url, json=payload, headers=self._request_headers(headers)
)
response.raise_for_status()
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{name}' not found", resource_type="sandbox"
) from e
if e.response.status_code == 409:
raise ResourceNameConflictError(
f"Sandbox name '{new_name}' already in use",
resource_type="sandbox",
) from e
handle_client_http_error(e)
raise # pragma: no cover
def delete_sandbox(self, name: str, *, headers: RequestHeaders = None) -> None:
"""Delete a Sandbox.
Args:
name: Sandbox name.
Raises:
ResourceNotFoundError: If sandbox not found.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/boxes/{name}"
try:
response = self._http.delete(url, headers=self._request_headers(headers))
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{name}' not found", resource_type="sandbox"
) from e
handle_client_http_error(e)
def get_sandbox_status(
self, name: str, *, headers: RequestHeaders = None
) -> ResourceStatus:
"""Get the provisioning status of a sandbox.
This is a lightweight endpoint designed for high-frequency polling
during sandbox provisioning. It returns only the status fields
without full sandbox data.
Args:
name: Sandbox name.
Returns:
ResourceStatus with status and status_message.
Raises:
ResourceNotFoundError: If sandbox not found.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/boxes/{name}/status"
try:
response = self._http.get(url, headers=self._request_headers(headers))
response.raise_for_status()
return ResourceStatus.from_dict(response.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{name}' not found", resource_type="sandbox"
) from e
handle_client_http_error(e)
raise # pragma: no cover
def service(
self,
name: str,
port: int,
*,
expires_in_seconds: int = 600,
headers: RequestHeaders = None,
) -> ServiceURL:
"""Get an authenticated URL for a service running inside a sandbox.
Returns a :class:`ServiceURL` whose properties auto-refresh the
token transparently before it expires. The object also provides
HTTP helper methods (``.get``, ``.post``, etc.) that inject the
authentication header automatically.
Args:
name: Sandbox name.
port: Port the service is listening on inside the sandbox.
expires_in_seconds: Token TTL in seconds (1--86400, default 600).
headers: Optional per-request header overrides.
Returns:
ServiceURL with auto-refreshing token and HTTP helpers.
Raises:
ResourceNotFoundError: If sandbox not found.
ValueError: If port or expires_in_seconds is out of range.
SandboxClientError: For other errors.
"""
validate_service_params(port, expires_in_seconds)
url = f"{self._base_url}/boxes/{name}/service-url"
payload = {"port": port, "expires_in_seconds": expires_in_seconds}
def _refresher() -> ServiceURL:
return self.service(
name,
port,
expires_in_seconds=expires_in_seconds,
headers=headers,
)
try:
response = self._http.post(
url, json=payload, headers=self._request_headers(headers)
)
response.raise_for_status()
return ServiceURL.from_dict(response.json(), _refresher=_refresher)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{name}' not found", resource_type="sandbox"
) from e
handle_client_http_error(e)
raise # pragma: no cover
def wait_for_sandbox(
self,
name: str,
*,
timeout: int = 120,
poll_interval: float = 1.0,
headers: RequestHeaders = None,
) -> Sandbox:
"""Poll until a sandbox reaches "ready" or "failed" status.
Uses the lightweight status endpoint for polling, then fetches the
full sandbox data once ready.
Args:
name: Sandbox name.
timeout: Maximum time to wait in seconds.
poll_interval: Time between status checks in seconds.
Returns:
Sandbox in "ready" status.
Raises:
ResourceCreationError: If sandbox status becomes "failed".
ResourceTimeoutError: If timeout expires while still "provisioning".
ResourceNotFoundError: If sandbox not found.
SandboxClientError: For other errors.
"""
import time
deadline = time.monotonic() + timeout
while True:
status = self.get_sandbox_status(name, headers=headers)
if status.status == "ready":
return self.get_sandbox(name, headers=headers)
if status.status == "failed":
raise ResourceCreationError(
status.status_message or "Sandbox provisioning failed",
resource_type="sandbox",
)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ResourceTimeoutError(
f"Sandbox '{name}' not ready after {timeout}s",
resource_type="sandbox",
last_status=status.status,
)
time.sleep(min(poll_interval, remaining))
def start_sandbox(
self,
name: str,
*,
timeout: int = 120,
headers: RequestHeaders = None,
) -> Sandbox:
"""Start a stopped sandbox and wait until ready.
Args:
name: Sandbox name.
timeout: Timeout in seconds when waiting for ready.
Returns:
Sandbox in "ready" status.
Raises:
ResourceNotFoundError: If sandbox not found.
ResourceCreationError: If sandbox fails during startup.
ResourceTimeoutError: If sandbox doesn't become ready within timeout.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/boxes/{name}/start"
try:
response = self._http.post(
url, json={}, headers=self._request_headers(headers)
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{name}' not found", resource_type="sandbox"
) from e
handle_client_http_error(e)
return self.wait_for_sandbox(name, timeout=timeout, headers=headers)
def stop_sandbox(self, name: str, *, headers: RequestHeaders = None) -> None:
"""Stop a running sandbox (preserves sandbox files for later restart).
Args:
name: Sandbox name.
Raises:
ResourceNotFoundError: If sandbox not found.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/boxes/{name}/stop"
try:
response = self._http.post(
url, json={}, headers=self._request_headers(headers)
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{name}' not found", resource_type="sandbox"
) from e
handle_client_http_error(e)
# ========================================================================
# Snapshot Operations
# ========================================================================
def create_snapshot(
self,
name: str,
docker_image: str,
fs_capacity_bytes: int,
*,
registry_id: Optional[str] = None,
registry_url: Optional[str] = None,
registry_username: Optional[str] = None,
registry_password: Optional[str] = None,
timeout: int = 60,
headers: RequestHeaders = None,
) -> Snapshot:
"""Build a snapshot from a Docker image.
Blocks until the snapshot is ready (polls with 2s interval).
Args:
name: Snapshot name.
docker_image: Docker image to build from (e.g., "python:3.12-slim").
fs_capacity_bytes: Filesystem capacity in bytes.
registry_id: Private registry ID (alternative to URL/credentials).
registry_url: Registry URL for private images.
registry_username: Registry username.
registry_password: Registry password.
timeout: Timeout in seconds when waiting for ready.
Returns:
Snapshot in "ready" status.
Raises:
ResourceTimeoutError: If snapshot doesn't become ready within timeout.
ResourceCreationError: If snapshot build fails.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/snapshots"
payload: dict[str, Any] = {
"name": name,
"docker_image": docker_image,
"fs_capacity_bytes": fs_capacity_bytes,
}
if registry_id is not None:
payload["registry_id"] = registry_id
if registry_url is not None:
payload["registry_url"] = registry_url
if registry_username is not None:
payload["registry_username"] = registry_username
if registry_password is not None:
payload["registry_password"] = registry_password
try:
response = self._http.post(
url, json=payload, headers=self._request_headers(headers)
)
response.raise_for_status()
snapshot = Snapshot.from_dict(response.json())
except httpx.HTTPStatusError as e:
handle_client_http_error(e)
raise # pragma: no cover
return self.wait_for_snapshot(snapshot.id, timeout=timeout, headers=headers)
def capture_snapshot(
self,
sandbox_name: str,
name: str,
*,
timeout: int = 60,
headers: RequestHeaders = None,
) -> Snapshot:
"""Capture a snapshot from a running sandbox.
Blocks until the snapshot is ready (polls with 2s interval).
Args:
sandbox_name: Name of the sandbox to capture from.
name: Snapshot name.
timeout: Timeout in seconds when waiting for ready.
Returns:
Snapshot in "ready" status.
Raises:
ResourceNotFoundError: If sandbox not found.
ResourceTimeoutError: If snapshot doesn't become ready within timeout.
ResourceCreationError: If snapshot capture fails.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/boxes/{sandbox_name}/snapshot"
payload: dict[str, Any] = {"name": name}
try:
response = self._http.post(
url, json=payload, headers=self._request_headers(headers)
)
response.raise_for_status()
snapshot = Snapshot.from_dict(response.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Sandbox '{sandbox_name}' not found", resource_type="sandbox"
) from e
handle_client_http_error(e)
raise # pragma: no cover
return self.wait_for_snapshot(snapshot.id, timeout=timeout, headers=headers)
def get_snapshot(
self, snapshot_id: str, *, headers: RequestHeaders = None
) -> Snapshot:
"""Get a snapshot by ID.
Args:
snapshot_id: Snapshot UUID.
Returns:
Snapshot.
Raises:
ResourceNotFoundError: If snapshot not found.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/snapshots/{snapshot_id}"
try:
response = self._http.get(url, headers=self._request_headers(headers))
response.raise_for_status()
return Snapshot.from_dict(response.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Snapshot '{snapshot_id}' not found", resource_type="snapshot"
) from e
handle_client_http_error(e)
raise # pragma: no cover
def list_snapshots(
self,
*,
name_contains: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
headers: RequestHeaders = None,
) -> list[Snapshot]:
"""List snapshots.
The backend always paginates this endpoint. When ``limit`` is omitted
the server applies a default page size (currently 50), so a single
call is not guaranteed to return every snapshot. To iterate through
all results, repeat the call with increasing ``offset`` values (or an
explicit ``limit``) until fewer than ``limit`` snapshots come back.
Args:
name_contains: Optional case-insensitive substring filter applied
to snapshot names server-side.
limit: Optional maximum number of snapshots to return for a single
request. Must be between 1 and 500 (inclusive); the server
rejects values outside that range. Defaults to 50 server-side
when omitted.
offset: Optional number of snapshots to skip before returning
results. Must be ``>= 0``. Useful for paginating through
large result sets in combination with ``limit``.
Returns:
A single page of Snapshots matching the provided filters.
"""
url = f"{self._base_url}/snapshots"
params: dict[str, Any] = {}
if name_contains is not None:
params["name_contains"] = name_contains
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
try:
response = self._http.get(
url,
params=params or None,
headers=self._request_headers(headers),
)
response.raise_for_status()
data = response.json()
return [Snapshot.from_dict(s) for s in data.get("snapshots", [])]
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise SandboxAPIError(
f"API endpoint not found: {url}. "
f"Check that api_endpoint is correct."
) from e
handle_client_http_error(e)
raise # pragma: no cover
def delete_snapshot(
self, snapshot_id: str, *, headers: RequestHeaders = None
) -> None:
"""Delete a snapshot.
Args:
snapshot_id: Snapshot UUID.
Raises:
ResourceNotFoundError: If snapshot not found.
SandboxClientError: For other errors.
"""
url = f"{self._base_url}/snapshots/{snapshot_id}"
try:
response = self._http.delete(url, headers=self._request_headers(headers))
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ResourceNotFoundError(
f"Snapshot '{snapshot_id}' not found", resource_type="snapshot"
) from e
handle_client_http_error(e)
def wait_for_snapshot(
self,
snapshot_id: str,
*,
timeout: int = 300,
poll_interval: float = 2.0,
headers: RequestHeaders = None,
) -> Snapshot:
"""Poll until a snapshot reaches "ready" or "failed" status.
Args:
snapshot_id: Snapshot UUID.
timeout: Maximum time to wait in seconds.
poll_interval: Time between status checks in seconds.
Returns:
Snapshot in "ready" status.
Raises:
ResourceCreationError: If snapshot status becomes "failed".
ResourceTimeoutError: If timeout expires.
ResourceNotFoundError: If snapshot not found.
SandboxClientError: For other errors.
"""
import time
deadline = time.monotonic() + timeout
while True:
snapshot = self.get_snapshot(snapshot_id, headers=headers)
if snapshot.status == "ready":
return snapshot
if snapshot.status == "failed":
raise ResourceCreationError(
snapshot.status_message or "Snapshot build failed",
resource_type="snapshot",
)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ResourceTimeoutError(
f"Snapshot '{snapshot_id}' not ready after {timeout}s",
resource_type="snapshot",
last_status=snapshot.status,
)
time.sleep(min(poll_interval, remaining))