diff --git a/src/aio_lib_sandbox/__init__.py b/src/aio_lib_sandbox/__init__.py index ee9809b..a6668a8 100644 --- a/src/aio_lib_sandbox/__init__.py +++ b/src/aio_lib_sandbox/__init__.py @@ -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, @@ -35,6 +38,7 @@ __all__ = [ "Sandbox", + "SANDBOX_PROTOCOL_VERSION", "DetachedCommandHandle", "ExecResult", "ExecTask", @@ -55,4 +59,8 @@ "SandboxTimeoutError", "SandboxWebSocketError", "SandboxCommandNotFoundError", + "ProtocolVersionMismatchError", + "SandboxMalformedFrameError", ] + +SANDBOX_PROTOCOL_VERSION = PROTOCOL_VERSION diff --git a/src/aio_lib_sandbox/constants.py b/src/aio_lib_sandbox/constants.py new file mode 100644 index 0000000..04f27b1 --- /dev/null +++ b/src/aio_lib_sandbox/constants.py @@ -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}" diff --git a/src/aio_lib_sandbox/errors.py b/src/aio_lib_sandbox/errors.py index 7d146fb..4de0178 100644 --- a/src/aio_lib_sandbox/errors.py +++ b/src/aio_lib_sandbox/errors.py @@ -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.""" diff --git a/src/aio_lib_sandbox/http.py b/src/aio_lib_sandbox/http.py index b9826c9..0b6ea13 100644 --- a/src/aio_lib_sandbox/http.py +++ b/src/aio_lib_sandbox/http.py @@ -11,6 +11,7 @@ import httpx +from .constants import API_PREFIX from .errors import ( SandboxClientError, SandboxNotFoundError, @@ -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, "", "", "")) diff --git a/src/aio_lib_sandbox/sandbox.py b/src/aio_lib_sandbox/sandbox.py index 3f392a8..a97e536 100644 --- a/src/aio_lib_sandbox/sandbox.py +++ b/src/aio_lib_sandbox/sandbox.py @@ -11,6 +11,7 @@ import httpx +from .constants import API_PREFIX, PROTOCOL_VERSION from .errors import ( SandboxClientError, SandboxInitializationError, @@ -44,6 +45,7 @@ class Sandbox: """ sizes = SANDBOX_SIZES + protocol_version = PROTOCOL_VERSION def __init__( self, @@ -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 @@ -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 @@ -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, @@ -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"], @@ -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, @@ -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"], @@ -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() diff --git a/src/aio_lib_sandbox/ws.py b/src/aio_lib_sandbox/ws.py index 8f5c47e..315fbe7 100644 --- a/src/aio_lib_sandbox/ws.py +++ b/src/aio_lib_sandbox/ws.py @@ -20,8 +20,10 @@ import websockets from .errors import ( + ProtocolVersionMismatchError, SandboxClientError, SandboxCommandNotFoundError, + SandboxMalformedFrameError, SandboxTimeoutError, SandboxUnauthorizedError, SandboxWebSocketError, @@ -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: diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 94c2f03..0972dd2 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -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, @@ -21,10 +23,12 @@ WriteResult, ) from aio_lib_sandbox.errors import ( + ProtocolVersionMismatchError, SandboxClientError, SandboxCommandNotFoundError, SandboxInitializationError, SandboxInvalidPortError, + SandboxMalformedFrameError, SandboxNotFoundError, SandboxPortNotProvisionedError, SandboxTimeoutError, @@ -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() # --------------------------------------------------------------------------- @@ -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", }, @@ -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", } @@ -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( @@ -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)): @@ -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