diff --git a/src/providers/exe/api.py b/src/providers/exe/api.py index 65eb001..3acba77 100644 --- a/src/providers/exe/api.py +++ b/src/providers/exe/api.py @@ -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. @@ -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 @@ -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, @@ -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") diff --git a/src/providers/exe/tests/test_api.py b/src/providers/exe/tests/test_api.py index 4148d6b..cc9b850 100644 --- a/src/providers/exe/tests/test_api.py +++ b/src/providers/exe/tests/test_api.py @@ -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):