Skip to content

Commit d72874c

Browse files
committed
address error types
1 parent 33f7080 commit d72874c

7 files changed

Lines changed: 361 additions & 10 deletions

File tree

hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
parse_json_response,
1212
resolve_runtime_transport_target,
1313
)
14-
from ...sandboxes.shared import _build_query_path
14+
from ...sandboxes.shared import _build_query_path, _is_replayable_http_content
1515

1616

1717
class RuntimeTransport:
@@ -167,6 +167,12 @@ async def _request(
167167

168168
if response.status_code == 401 and allow_refresh:
169169
await response.aclose()
170+
if not _is_replayable_http_content(content):
171+
return ensure_response_ok(
172+
response,
173+
"runtime",
174+
"Runtime token expired during a streaming request; retry with a fresh stream.",
175+
)
170176
refreshed = await self._resolve_connection(True)
171177
retry = await self._send(
172178
refreshed,

hyperbrowser/client/managers/sandboxes/image_build.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@
88
import uuid
99
from dataclasses import dataclass
1010
from pathlib import Path
11-
from typing import Dict, List, Optional, Sequence
11+
from typing import Dict, FrozenSet, List, Optional, Sequence
1212

1313
import httpx
1414

15-
from ....models.sandbox import SandboxImageBuildUpload, SandboxImageInit
15+
from ....models.sandbox import (
16+
SandboxImageBuildInputFormat,
17+
SandboxImageBuildStatus,
18+
SandboxImageBuildUpload,
19+
SandboxImageInit,
20+
)
1621

17-
IMAGE_BUILD_INPUT_FORMAT = "rootfs_export_tar_gz"
22+
IMAGE_BUILD_INPUT_FORMAT: SandboxImageBuildInputFormat = "rootfs_export_tar_gz"
1823
IMAGE_BUILD_SOURCE_PLATFORM = "linux/amd64"
24+
TERMINAL_IMAGE_BUILD_STATUSES: FrozenSet[SandboxImageBuildStatus] = frozenset(
25+
{"completed", "failed", "canceled", "cancelled"}
26+
)
1927
_IMAGE_INIT_ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
2028
_RESERVED_IMAGE_INIT_ENV_KEYS = {
2129
"SANDBOX_ENABLED",
@@ -35,7 +43,7 @@ class DockerImageBuildArtifact:
3543
path: str
3644
sha256_hex: str
3745
size_bytes: int
38-
input_format: str
46+
input_format: SandboxImageBuildInputFormat
3947
source_platform: str
4048
image_config_user: str
4149
image_init: Optional[SandboxImageInit]
@@ -161,8 +169,8 @@ def make_temp_docker_tag(prefix: str = "hyperbrowser-sdk-build") -> str:
161169
return f"{prefix}:{uuid.uuid4().hex}"
162170

163171

164-
def is_terminal_image_build_status(status: str) -> bool:
165-
return status.strip().lower() in {"completed", "failed", "canceled", "cancelled"}
172+
def is_terminal_image_build_status(status: SandboxImageBuildStatus) -> bool:
173+
return status in TERMINAL_IMAGE_BUILD_STATUSES
166174

167175

168176
def _ensure_docker_image_source_platform(docker_image: str, platform: str) -> None:
@@ -232,6 +240,7 @@ def _package_docker_container(
232240
hasher = hashlib.sha256()
233241
writer = _HashingCountingWriter(tmp, hasher)
234242
stderr_file = tempfile.TemporaryFile()
243+
process = None
235244
try:
236245
process = subprocess.Popen(
237246
["docker", "export", container_id],
@@ -272,11 +281,27 @@ def _package_docker_container(
272281
pass
273282
raise
274283
finally:
284+
if process is not None:
285+
_cleanup_docker_export_process(process)
275286
tmp.close()
276287
stderr_file.close()
277288
_remove_docker_container(container_id)
278289

279290

291+
def _cleanup_docker_export_process(process) -> None:
292+
if process.stdout is not None:
293+
process.stdout.close()
294+
if process.poll() is None:
295+
process.terminate()
296+
try:
297+
process.wait(timeout=5)
298+
except subprocess.TimeoutExpired:
299+
process.kill()
300+
process.wait()
301+
return
302+
process.wait()
303+
304+
280305
def _derive_auto_image_init(config: Dict[str, object]) -> Optional[SandboxImageInit]:
281306
env = _derive_auto_image_env(_list_string_config(config.get("Env")))
282307
args = _derive_auto_startup_args(

hyperbrowser/client/managers/sandboxes/shared.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import collections.abc
23
import posixpath
34
import re
45
from datetime import datetime, timedelta, timezone
@@ -28,6 +29,24 @@ def _copy_model(model):
2829
return model.model_copy(deep=True)
2930

3031

32+
def _is_replayable_http_content(content) -> bool:
33+
if content is None:
34+
return True
35+
if isinstance(content, (bytes, bytearray, memoryview, str)):
36+
return True
37+
if hasattr(content, "read"):
38+
return False
39+
if hasattr(content, "__aiter__"):
40+
return False
41+
if isinstance(content, collections.abc.Iterator):
42+
return False
43+
try:
44+
iter(content)
45+
except TypeError:
46+
return True
47+
return True
48+
49+
3150
def _quote_shell_token(token: str) -> str:
3251
if token == "":
3352
return "''"

hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
parse_json_response,
1212
resolve_runtime_transport_target,
1313
)
14-
from ...sandboxes.shared import _build_query_path
14+
from ...sandboxes.shared import _build_query_path, _is_replayable_http_content
1515

1616

1717
class RuntimeTransport:
@@ -165,6 +165,12 @@ def _request(
165165

166166
if response.status_code == 401 and allow_refresh:
167167
response.close()
168+
if not _is_replayable_http_content(content):
169+
return ensure_response_ok(
170+
response,
171+
"runtime",
172+
"Runtime token expired during a streaming request; retry with a fresh stream.",
173+
)
168174
refreshed = self._resolve_connection(True)
169175
retry = self._send(
170176
refreshed,

hyperbrowser/models/sandbox.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ class SandboxImageBuild(SandboxBaseModel):
397397
namespace: Optional[str] = None
398398
image_name: str = Field(alias="imageName")
399399
image_id: Optional[str] = Field(default=None, alias="imageId")
400-
status: str
400+
status: SandboxImageBuildStatus
401401
input_bucket: Optional[str] = Field(default=None, alias="inputBucket")
402402
input_key: Optional[str] = Field(default=None, alias="inputKey")
403403
input_sha256: Optional[str] = Field(default=None, alias="inputSha256")

tests/test_sandbox_image_build_helpers.py

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import httpx
55
import pytest
6+
from pydantic import ValidationError
67

78
import hyperbrowser.client.managers.async_manager.sandbox as async_sandbox_module
89
import hyperbrowser.client.managers.sync_manager.sandbox as sync_sandbox_module
@@ -11,7 +12,27 @@
1112
SandboxManager as AsyncSandboxManager,
1213
)
1314
from hyperbrowser.client.managers.sync_manager.sandbox import SandboxManager
14-
from hyperbrowser.models import SandboxImageBuildUpload
15+
from hyperbrowser.models import SandboxImageBuild, SandboxImageBuildUpload
16+
17+
18+
def _image_build(
19+
status: str,
20+
*,
21+
error_code: str = "",
22+
error_message: str = "",
23+
) -> SandboxImageBuild:
24+
return SandboxImageBuild(
25+
id="build-123",
26+
imageName="custom",
27+
status=status,
28+
errorCode=error_code,
29+
errorMessage=error_message,
30+
)
31+
32+
33+
def test_image_build_model_rejects_invalid_status_casing():
34+
with pytest.raises(ValidationError):
35+
_image_build(" Completed ")
1536

1637

1738
def test_build_docker_image_from_dockerfile_targets_linux_amd64(monkeypatch, tmp_path):
@@ -61,6 +82,71 @@ def fake_run(args, check, stdout, stderr, text):
6182
image_build.package_docker_image("local/app:latest")
6283

6384

85+
def test_package_docker_container_reaps_export_process_on_read_failure(
86+
monkeypatch,
87+
tmp_path,
88+
):
89+
removed = []
90+
91+
class BrokenStdout:
92+
def __init__(self):
93+
self.closed = False
94+
95+
def read(self, size):
96+
raise RuntimeError("read failed")
97+
98+
def close(self):
99+
self.closed = True
100+
101+
class FakeProcess:
102+
def __init__(self):
103+
self.stdout = BrokenStdout()
104+
self.terminated = False
105+
self.killed = False
106+
self.waits = 0
107+
self.return_code = None
108+
109+
def poll(self):
110+
return self.return_code
111+
112+
def terminate(self):
113+
self.terminated = True
114+
self.return_code = -15
115+
116+
def kill(self):
117+
self.killed = True
118+
self.return_code = -9
119+
120+
def wait(self, timeout=None):
121+
self.waits += 1
122+
if self.return_code is None:
123+
self.return_code = 0
124+
return self.return_code
125+
126+
fake_process = FakeProcess()
127+
128+
def fake_popen(args, stdout, stderr):
129+
return fake_process
130+
131+
monkeypatch.setattr(image_build.subprocess, "Popen", fake_popen)
132+
monkeypatch.setattr(image_build, "_remove_docker_container", removed.append)
133+
134+
with pytest.raises(RuntimeError, match="read failed"):
135+
image_build._package_docker_container(
136+
"local/app:latest",
137+
"container-123",
138+
{},
139+
platform="linux/amd64",
140+
temp_dir=str(tmp_path),
141+
)
142+
143+
assert fake_process.stdout.closed is True
144+
assert fake_process.terminated is True
145+
assert fake_process.killed is False
146+
assert fake_process.waits == 1
147+
assert removed == ["container-123"]
148+
149+
64150
def test_upload_image_build_artifact_streams_file_with_content_length(
65151
monkeypatch,
66152
tmp_path,
@@ -140,6 +226,48 @@ def fake_build(**kwargs):
140226
assert removed == ["temp:tag"]
141227

142228

229+
def test_sync_wait_for_image_build_returns_completed_status(monkeypatch):
230+
manager = SandboxManager(
231+
SimpleNamespace(
232+
timeout=30,
233+
config=SimpleNamespace(runtime_proxy_override=None),
234+
)
235+
)
236+
monkeypatch.setattr(
237+
manager,
238+
"get_image_build",
239+
lambda build_id: _image_build("completed"),
240+
)
241+
242+
build = manager.wait_for_image_build("build-123", poll_interval=0, timeout=1)
243+
244+
assert build.status == "completed"
245+
246+
247+
def test_sync_wait_for_image_build_raises_detailed_failed_status(monkeypatch):
248+
manager = SandboxManager(
249+
SimpleNamespace(
250+
timeout=30,
251+
config=SimpleNamespace(runtime_proxy_override=None),
252+
)
253+
)
254+
monkeypatch.setattr(
255+
manager,
256+
"get_image_build",
257+
lambda build_id: _image_build(
258+
"failed",
259+
error_code="E_BAD",
260+
error_message="backend failure",
261+
),
262+
)
263+
264+
with pytest.raises(
265+
RuntimeError,
266+
match=r"image build failed \[E_BAD\]: backend failure",
267+
):
268+
manager.wait_for_image_build("build-123", poll_interval=0, timeout=1)
269+
270+
143271
@pytest.mark.anyio
144272
async def test_async_dockerfile_image_build_cleans_temp_tag_on_build_failure(
145273
monkeypatch,
@@ -175,3 +303,55 @@ def fake_build(**kwargs):
175303
)
176304

177305
assert removed == ["temp:tag"]
306+
307+
308+
@pytest.mark.anyio
309+
async def test_async_wait_for_image_build_returns_completed_status(monkeypatch):
310+
manager = AsyncSandboxManager(
311+
SimpleNamespace(
312+
timeout=30,
313+
config=SimpleNamespace(runtime_proxy_override=None),
314+
)
315+
)
316+
317+
async def fake_get_image_build(build_id):
318+
return _image_build("completed")
319+
320+
monkeypatch.setattr(manager, "get_image_build", fake_get_image_build)
321+
322+
build = await manager.wait_for_image_build(
323+
"build-123",
324+
poll_interval=0,
325+
timeout=1,
326+
)
327+
328+
assert build.status == "completed"
329+
330+
331+
@pytest.mark.anyio
332+
async def test_async_wait_for_image_build_raises_detailed_failed_status(monkeypatch):
333+
manager = AsyncSandboxManager(
334+
SimpleNamespace(
335+
timeout=30,
336+
config=SimpleNamespace(runtime_proxy_override=None),
337+
)
338+
)
339+
340+
async def fake_get_image_build(build_id):
341+
return _image_build(
342+
"failed",
343+
error_code="E_BAD",
344+
error_message="backend failure",
345+
)
346+
347+
monkeypatch.setattr(manager, "get_image_build", fake_get_image_build)
348+
349+
with pytest.raises(
350+
RuntimeError,
351+
match=r"image build failed \[E_BAD\]: backend failure",
352+
):
353+
await manager.wait_for_image_build(
354+
"build-123",
355+
poll_interval=0,
356+
timeout=1,
357+
)

0 commit comments

Comments
 (0)