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
8 changes: 8 additions & 0 deletions src/aio_lib_sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@

__version__ = "0.1.0a8"

from .constants import PROTOCOL_VERSION
from .errors import (
ProtocolVersionMismatchError,
SandboxClientError,
SandboxCommandNotFoundError,
SandboxInitializationError,
SandboxInvalidPortError,
SandboxMalformedFrameError,
SandboxNotFoundError,
SandboxPortNotProvisionedError,
SandboxSDKError,
Expand All @@ -35,6 +38,7 @@

__all__ = [
"Sandbox",
"SANDBOX_PROTOCOL_VERSION",
"DetachedCommandHandle",
"ExecResult",
"ExecTask",
Expand All @@ -55,4 +59,8 @@
"SandboxTimeoutError",
"SandboxWebSocketError",
"SandboxCommandNotFoundError",
"ProtocolVersionMismatchError",
"SandboxMalformedFrameError",
]

SANDBOX_PROTOCOL_VERSION = PROTOCOL_VERSION
7 changes: 7 additions & 0 deletions src/aio_lib_sandbox/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copyright 2026 Adobe. All rights reserved.
# Licensed under the Apache License, Version 2.0.

"""SDK-owned sandbox wire protocol constants."""

PROTOCOL_VERSION = "1"
API_PREFIX = f"/api/v{PROTOCOL_VERSION}"
8 changes: 8 additions & 0 deletions src/aio_lib_sandbox/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ class SandboxPortNotProvisionedError(SandboxClientError):

class SandboxInvalidPortError(SandboxClientError):
"""Port value is not a valid integer in the range 1–65535."""


class ProtocolVersionMismatchError(SandboxClientError):
"""Sandbox protocol major is incompatible with this SDK."""


class SandboxMalformedFrameError(SandboxClientError):
"""Sandbox rejected a WebSocket command frame as malformed JSON."""
3 changes: 2 additions & 1 deletion src/aio_lib_sandbox/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import httpx

from .constants import API_PREFIX
from .errors import (
SandboxClientError,
SandboxNotFoundError,
Expand Down Expand Up @@ -46,7 +47,7 @@ def normalize_api_host(host: str) -> str:
def build_ws_endpoint(api_host: str, namespace: str, sandbox_id: str) -> str:
parsed = urlparse(api_host)
ws_scheme = "ws" if parsed.scheme == "http" else "wss"
path = f"/api/v1/namespaces/{namespace}/sandboxes/{sandbox_id}/exec"
path = f"{API_PREFIX}/namespaces/{namespace}/sandboxes/{sandbox_id}/exec"
return urlunparse((ws_scheme, parsed.netloc, path, "", "", ""))


Expand Down
12 changes: 9 additions & 3 deletions src/aio_lib_sandbox/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import httpx

from .constants import API_PREFIX, PROTOCOL_VERSION
from .errors import (
SandboxClientError,
SandboxInitializationError,
Expand Down Expand Up @@ -44,6 +45,7 @@ class Sandbox:
"""

sizes = SANDBOX_SIZES
protocol_version = PROTOCOL_VERSION

def __init__(
self,
Expand All @@ -61,6 +63,7 @@ def __init__(
token: str | None = None,
preview_urls: dict[int, str] | None = None,
management_endpoint: str | None = None,
protocol_version: str | None = None,
verify_ssl: bool = True,
) -> None:
self.id = sandbox_id
Expand All @@ -77,6 +80,7 @@ def __init__(
self.token = token
self.preview_urls: dict[int, str] = preview_urls or {}
self.management_endpoint = management_endpoint
self.protocol_version = protocol_version or PROTOCOL_VERSION
self.verify_ssl = verify_ssl

self.session: WsSession | None = None
Expand Down Expand Up @@ -157,7 +161,7 @@ async def create(
if ports is not None:
body["ports"] = ports

url = f"{creds['api_host']}/api/v1/namespaces/{creds['namespace']}/sandboxes"
url = f"{creds['api_host']}{API_PREFIX}/namespaces/{creds['namespace']}/sandboxes"
payload = await api_request(
"POST",
url,
Expand All @@ -181,6 +185,7 @@ async def create(
max_lifetime=payload.get("maxLifetime", 3600),
preview_urls=_parse_preview_urls(payload.get("previewUrls")),
management_endpoint=payload.get("managementEndpoint"),
protocol_version=payload.get("protocolVersion") or PROTOCOL_VERSION,
namespace=creds["namespace"],
api_host=creds["api_host"],
api_key=creds["api_key"],
Expand Down Expand Up @@ -224,7 +229,7 @@ async def get(
"""
creds = cls.resolve_credentials(api_host=api_host, namespace=namespace, auth=auth)
base = management_endpoint or creds["api_host"]
url = f"{base}/api/v1/namespaces/{creds['namespace']}/sandboxes/{sandbox_id}"
url = f"{base}{API_PREFIX}/namespaces/{creds['namespace']}/sandboxes/{sandbox_id}"
payload = await api_request(
"GET",
url,
Expand All @@ -243,6 +248,7 @@ async def get(
max_lifetime=payload.get("maxLifetime", 3600),
management_endpoint=payload.get("managementEndpoint") or management_endpoint,
preview_urls=_parse_preview_urls(payload.get("previewUrls")),
protocol_version=payload.get("protocolVersion") or PROTOCOL_VERSION,
namespace=creds["namespace"],
api_host=creds["api_host"],
api_key=creds["api_key"],
Expand Down Expand Up @@ -495,7 +501,7 @@ async def destroy(self) -> dict[str, Any]:
The destroy response payload.
"""
base = self.management_endpoint or self.api_host
url = f"{base}/api/v1/namespaces/{self.namespace}/sandboxes/{self.id}"
url = f"{base}{API_PREFIX}/namespaces/{self.namespace}/sandboxes/{self.id}"
headers = {"Authorization": build_auth_header(self.api_key)}
if self.session:
self.session.begin_intentional_close()
Expand Down
12 changes: 11 additions & 1 deletion src/aio_lib_sandbox/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
import websockets

from .errors import (
ProtocolVersionMismatchError,
SandboxClientError,
SandboxCommandNotFoundError,
SandboxMalformedFrameError,
SandboxTimeoutError,
SandboxUnauthorizedError,
SandboxWebSocketError,
Expand Down Expand Up @@ -298,7 +300,15 @@ async def listen(self) -> None:
self.resolve_all_on_intentional_close()
return
close_code = exc.rcvd.code if exc.rcvd is not None else 1006
self.reject_all(SandboxWebSocketError(f"Sandbox '{self.id}' WebSocket closed with code {close_code}"))
if close_code == 4003:
error = ProtocolVersionMismatchError(
f"Sandbox '{self.id}' WebSocket protocol version does not match this SDK"
)
elif close_code == 4004:
error = SandboxMalformedFrameError(f"Sandbox '{self.id}' rejected a malformed WebSocket frame")
else:
error = SandboxWebSocketError(f"Sandbox '{self.id}' WebSocket closed with code {close_code}")
self.reject_all(error)
finally:
self.ws = None
if self.intentional_close:
Expand Down
48 changes: 48 additions & 0 deletions tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import httpx
import pytest
import websockets
from websockets.frames import Close

from aio_lib_sandbox import (
SANDBOX_PROTOCOL_VERSION,
SANDBOX_SIZES,
DetachedCommandHandle,
ExecResult,
Expand All @@ -21,10 +23,12 @@
WriteResult,
)
from aio_lib_sandbox.errors import (
ProtocolVersionMismatchError,
SandboxClientError,
SandboxCommandNotFoundError,
SandboxInitializationError,
SandboxInvalidPortError,
SandboxMalformedFrameError,
SandboxNotFoundError,
SandboxPortNotProvisionedError,
SandboxTimeoutError,
Expand Down Expand Up @@ -177,6 +181,12 @@ def test_sizes_class_attr(self):
assert Sandbox.sizes is SANDBOX_SIZES


class TestProtocolVersion:
def test_protocol_major_is_bundled(self):
assert SANDBOX_PROTOCOL_VERSION == "1"
assert Sandbox.protocol_version == "1"


# ---------------------------------------------------------------------------
# Sandbox.create()
# ---------------------------------------------------------------------------
Expand All @@ -191,6 +201,7 @@ async def test_create_calls_api_and_connects(self, monkeypatch):
"status": "ready",
"token": "tok-new",
"maxLifetime": 3600,
"protocolVersion": "1",
"previewUrls": {
"3000": "https://sb-new-3000.preview.example.net",
},
Expand All @@ -209,6 +220,7 @@ async def test_create_calls_api_and_connects(self, monkeypatch):

assert sandbox.id == "sb-new"
assert sandbox.status == "ready"
assert sandbox.protocol_version == "1"
assert sandbox.preview_urls == {
3000: "https://sb-new-3000.preview.example.net",
}
Expand Down Expand Up @@ -535,6 +547,40 @@ async def test_listen_rejects_pending_on_unintentional_close(self):
await future
assert session.ws is None

@pytest.mark.asyncio
async def test_listen_rejects_protocol_mismatch_close_with_typed_error(self):
session = WsSession(
sandbox_id="sb-test",
endpoint="wss://runtime.example.net/ws",
token="tok-abc",
)
future = asyncio.get_running_loop().create_future()
session.pending_execs["exec-1"] = PendingExec(future=future)
session.ws = _AsyncFrameStream(websockets.ConnectionClosedError(Close(4003, "protocol_version_mismatch"), None))

await session.listen()

with pytest.raises(ProtocolVersionMismatchError):
await future
assert session.ws is None

@pytest.mark.asyncio
async def test_listen_rejects_malformed_frame_close_with_typed_error(self):
session = WsSession(
sandbox_id="sb-test",
endpoint="wss://runtime.example.net/ws",
token="tok-abc",
)
future = asyncio.get_running_loop().create_future()
session.pending_execs["exec-1"] = PendingExec(future=future)
session.ws = _AsyncFrameStream(websockets.ConnectionClosedError(Close(4004, "malformed_frame"), None))

await session.listen()

with pytest.raises(SandboxMalformedFrameError):
await future
assert session.ws is None

@pytest.mark.asyncio
async def test_listen_resolves_pending_on_intentional_close(self):
session = WsSession(
Expand Down Expand Up @@ -573,6 +619,7 @@ async def test_get_returns_sandbox_with_status(self):
"status": "running",
"cluster": "cluster-b",
"region": "va6",
"protocolVersion": "1",
}

with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)):
Expand All @@ -586,6 +633,7 @@ async def test_get_returns_sandbox_with_status(self):
assert sandbox.id == "sb-get"
assert sandbox.status == "running"
assert sandbox.cluster == "cluster-b"
assert sandbox.protocol_version == "1"
assert sandbox.session is None

@pytest.mark.asyncio
Expand Down
Loading