Skip to content
Merged
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
28 changes: 22 additions & 6 deletions src/providers/exe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
)
from .settings import ExeSettings

# Creating the VM itself is fast, but `new` waits on the setup script, and the
# tailscale join inside it is slow: ~21s healthy, uncomfortably close to the
# default 30s budget. Creation alone gets this read floor; a configured timeout
# above it wins.
_CREATE_VM_READ_TIMEOUT_FLOOR = 90.0


def _encode_setup_script(script: str) -> str:
"""Encode a multi-line script as a double-quoted exe.dev argument value.
Expand Down Expand Up @@ -43,6 +49,9 @@ def __init__(
self.token = token
self.default_image = default_image
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
self.create_vm_timeout = httpx.Timeout(
timeout, connect=connect_timeout, read=max(timeout, _CREATE_VM_READ_TIMEOUT_FLOOR)
)
self._client: httpx.AsyncClient | None = None

@classmethod
Expand Down Expand Up @@ -91,7 +100,7 @@ async def create_vm(
if env:
for key, value in env.items():
command_parts.extend(["--env", shlex.quote(f"{key}={value}")])
return await self._exec_dict(" ".join(command_parts))
return await self._exec_dict(" ".join(command_parts), timeout=self.create_vm_timeout)

async def list_vms(
self,
Expand Down Expand Up @@ -211,19 +220,26 @@ async def aclose(self) -> None:
await self._client.aclose()
self._client = None

async def _exec_dict(self, command: str) -> dict[str, Any]:
response = await self._request(command)
async def _exec_dict(
self, command: str, *, timeout: httpx.Timeout | None = None
) -> dict[str, Any]:
response = await self._request(command, timeout=timeout)
payload = self._parse_json_response(response.text)

if not isinstance(payload, dict):
raise ExeResponseError("exe.dev API returned non-object JSON output")
return payload

async def _request(self, command: str) -> httpx.Response:
async def _request(
self, command: str, *, timeout: httpx.Timeout | None = None
) -> httpx.Response:
try:
response = await self._get_client().post("/exec", content=command)
response = await self._get_client().post(
"/exec", content=command, timeout=timeout or self.timeout
)
except httpx.RequestError as exc:
raise ExeResponseError(f"exe.dev API transport failed: {exc}") from exc
# {exc!r}, not {exc}: bare transport errors like ReadTimeout() stringify empty.
raise ExeResponseError(f"exe.dev API transport failed: {exc!r}") from exc

if response.status_code == 401:
raise ExeAuthError("exe.dev API authentication failed")
Expand Down
60 changes: 60 additions & 0 deletions src/providers/exe/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,66 @@ async def test_request_sends_authorization_and_text_body(respx_mock):
assert call.request.content == b"whoami --json"


@pytest.mark.asyncio
@respx.mock(base_url="https://exe.dev")
async def test_request_names_exception_type_for_blank_transport_error(respx_mock):
read_timeout = httpx.ReadTimeout("")
respx_mock.post("/exec").mock(side_effect=read_timeout)

with pytest.raises(ExeResponseError, match="ReadTimeout") as exc_info:
await _api()._request("new --json")

assert exc_info.value.__cause__ is read_timeout


@pytest.mark.asyncio
@respx.mock(base_url="https://exe.dev")
async def test_create_vm_raises_read_timeout_floor_when_general_timeout_below(respx_mock):
route = respx_mock.post("/exec").mock(
return_value=httpx.Response(200, content=b'{"vm_name": "sb-1", "ssh_port": 22}'),
)

# The default 30s timeout sits below the creation floor.
await ExeAPI(base_url="https://exe.dev", token="token").create_vm(
name="sb-1", image="ubuntu:22.04"
)

timeout = route.calls.last.request.extensions["timeout"]
assert timeout["read"] == 90.0
assert timeout["connect"] == 5.0
assert timeout["write"] == 30.0
assert timeout["pool"] == 30.0


@pytest.mark.asyncio
@respx.mock(base_url="https://exe.dev")
async def test_create_vm_keeps_configured_timeout_when_above_floor(respx_mock):
route = respx_mock.post("/exec").mock(
return_value=httpx.Response(200, content=b'{"vm_name": "sb-1", "ssh_port": 22}'),
)

await ExeAPI(base_url="https://exe.dev", token="token", timeout=120.0).create_vm(
name="sb-1", image="ubuntu:22.04"
)

assert route.calls.last.request.extensions["timeout"]["read"] == 120.0


@pytest.mark.asyncio
@respx.mock(base_url="https://exe.dev")
async def test_ordinary_command_after_create_vm_uses_general_timeout(respx_mock):
route = respx_mock.post("/exec").mock(
return_value=httpx.Response(200, content=b'{"vm_name": "sb-1", "ssh_port": 22}'),
)

# Creation must not leak its bigger budget into later commands on the client.
api = ExeAPI(base_url="https://exe.dev", token="token")
await api.create_vm(name="sb-1", image="ubuntu:22.04")
await api.whoami()

assert route.calls.last.request.extensions["timeout"]["read"] == 30.0


@pytest.mark.asyncio
@respx.mock(base_url="https://exe.dev")
async def test_delete_vm_uses_rm_json_command(respx_mock):
Expand Down