Skip to content

Commit 1f291b5

Browse files
committed
fix: standardize sandbox data api errors
Raise ClientError/ServerError for Sandbox HTTP JSON error responses and expose structured error metadata on HTTPError. Document the breaking migration in release notes. Tests: uv run pytest tests/unittests/utils/test_exception.py tests/unittests/sandbox/api/test_sandbox_data.py; uv run pytest tests/unittests/sandbox/api/test_code_interpreter_data.py tests/unittests/sandbox/api/test_browser_data.py tests/unittests/sandbox/api/test_aio_data.py tests/unittests/sandbox/test_client.py. Type check: targeted mypy passed for modified files. Full mypy is blocked by existing duplicate module sandbox from local/sandbox/__init__.py and examples/sandbox.py. Signed-off-by: 寒光 <2510399607@qq.com>
1 parent 0624498 commit 1f291b5

6 files changed

Lines changed: 554 additions & 24 deletions

File tree

RELEASE_NOTES.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Release Notes
2+
3+
## Unreleased
4+
5+
### Breaking Changes
6+
7+
- Sandbox data-plane HTTP JSON APIs now follow standard HTTP error handling:
8+
- `2xx` responses return the business response body.
9+
- `4xx` responses raise `ClientError`.
10+
- `5xx` responses raise `ServerError`.
11+
- Error responses such as `{"code": "...", "requestId": "...", "message": "..."}`
12+
are no longer returned as normal dictionaries for Sandbox HTTP JSON APIs. The
13+
fields are exposed on the raised exception as `error_code`, `request_id`, and
14+
`message`.
15+
- Existing code that checked returned dictionaries for `code` and `requestId`
16+
must migrate to `try` / `except ClientError` / `except ServerError`.
17+
18+
### Migration
19+
20+
Before:
21+
22+
```python
23+
resp = ci.cmd(command="echo hello", cwd="/tmp", timeout=30)
24+
if "code" in resp and "requestId" in resp:
25+
raise RuntimeError(resp["message"])
26+
```
27+
28+
After:
29+
30+
```python
31+
from agentrun.utils.exception import ClientError, ServerError
32+
33+
try:
34+
resp = ci.cmd(command="echo hello", cwd="/tmp", timeout=30)
35+
except ClientError as e:
36+
print(e.status_code, e.error_code, e.request_id, e.message)
37+
raise
38+
except ServerError as e:
39+
print(e.status_code, e.error_code, e.request_id, e.message)
40+
raise
41+
```
42+
43+
Command execution failures are still business-level failures and should be
44+
handled by checking `resp["result"]["exitCode"]` after a successful HTTP
45+
response.
46+
47+
### Scope
48+
49+
This change is intentionally limited to Sandbox data-plane HTTP JSON APIs. It
50+
does not change WebSocket/CDP/VNC URL generation, Playwright connections, file
51+
upload/download helpers, video download helpers, or non-Sandbox data-plane
52+
clients.

agentrun/sandbox/api/__sandbox_data_async_template.py

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,23 @@
44
This template is used to generate sandbox data API code.
55
"""
66

7-
from typing import Any, Dict, Optional
7+
from typing import Any, Dict, Optional, Tuple, Union
8+
9+
import httpx
810

911
from agentrun.utils.config import Config
1012
from agentrun.utils.data_api import DataAPI, ResourceType
13+
from agentrun.utils.exception import ClientError, ServerError
14+
from agentrun.utils.log import logger
1115

1216

1317
class SandboxDataAPI(DataAPI):
18+
_REQUEST_ID_HEADERS = (
19+
"x-acs-request-id",
20+
"x-agentrun-request-id",
21+
"x-request-id",
22+
"x-fc-request-id",
23+
)
1424

1525
def __init__(
1626
self,
@@ -63,6 +73,126 @@ def __refresh_access_token(
6373
self.auth(config=cfg)
6474
self.access_token_map[sandbox_id or template_name] = self.access_token
6575

76+
@classmethod
77+
def _extract_error_fields(
78+
cls, response: httpx.Response, response_body: Any
79+
) -> Tuple[Optional[str], Optional[str], str]:
80+
error_code = None
81+
request_id = None
82+
message = ""
83+
84+
if isinstance(response_body, dict):
85+
raw_code = response_body.get("code")
86+
if raw_code is not None:
87+
error_code = str(raw_code)
88+
89+
raw_request_id = response_body.get("requestId")
90+
if raw_request_id is not None:
91+
request_id = str(raw_request_id)
92+
93+
raw_message = response_body.get("message")
94+
if raw_message is not None:
95+
message = str(raw_message)
96+
elif isinstance(response_body, str):
97+
message = response_body.strip()
98+
99+
if request_id is None:
100+
for header in cls._REQUEST_ID_HEADERS:
101+
value = response.headers.get(header)
102+
if value:
103+
request_id = value
104+
break
105+
106+
if not message:
107+
message = (
108+
response.reason_phrase or f"HTTP {response.status_code} error"
109+
)
110+
111+
return error_code, request_id, message
112+
113+
@staticmethod
114+
def _parse_error_response_body(response: httpx.Response) -> Any:
115+
if not response.text:
116+
return {}
117+
try:
118+
return response.json()
119+
except ValueError:
120+
return response.text
121+
122+
@staticmethod
123+
def _parse_success_response(response: httpx.Response) -> Dict[str, Any]:
124+
if not response.text:
125+
return {}
126+
try:
127+
return response.json()
128+
except ValueError as e:
129+
error_msg = f"Failed to parse JSON response: {e}"
130+
logger.error(error_msg)
131+
raise ServerError(
132+
status_code=response.status_code,
133+
message=error_msg,
134+
response_body=response.text,
135+
response_headers=dict(response.headers),
136+
) from e
137+
138+
@classmethod
139+
def _raise_for_error_response(cls, response: httpx.Response) -> None:
140+
response_body = cls._parse_error_response_body(response)
141+
error_code, request_id, message = cls._extract_error_fields(
142+
response, response_body
143+
)
144+
if response.status_code >= 500:
145+
raise ServerError(
146+
status_code=response.status_code,
147+
message=message,
148+
request_id=request_id,
149+
error_code=error_code,
150+
response_body=response_body,
151+
response_headers=dict(response.headers),
152+
)
153+
raise ClientError(
154+
status_code=response.status_code,
155+
message=message,
156+
request_id=request_id,
157+
error_code=error_code,
158+
response_body=response_body,
159+
response_headers=dict(response.headers),
160+
)
161+
162+
async def _make_request_async(
163+
self,
164+
method: str,
165+
url: str,
166+
data: Optional[Union[Dict[str, Any], str]] = None,
167+
headers: Optional[Dict[str, str]] = None,
168+
query: Optional[Dict[str, Any]] = None,
169+
config: Optional[Config] = None,
170+
) -> Dict[str, Any]:
171+
method, url, req_headers, req_json, req_content = self._prepare_request(
172+
method, url, data, headers, query, config=config
173+
)
174+
175+
try:
176+
async with httpx.AsyncClient(
177+
timeout=self.config.get_timeout()
178+
) as client:
179+
response = await client.request(
180+
method,
181+
url,
182+
headers=req_headers,
183+
json=req_json,
184+
content=req_content,
185+
)
186+
logger.debug(f"Response: {response.text}")
187+
188+
if response.status_code >= 400:
189+
self._raise_for_error_response(response)
190+
191+
return self._parse_success_response(response)
192+
except httpx.RequestError as e:
193+
error_msg = f"Request error: {e!s}"
194+
raise ClientError(status_code=0, message=error_msg) from e
195+
66196
async def check_health_async(self):
67197
return await self.get_async("/health")
68198

agentrun/sandbox/api/sandbox_data.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,23 @@
1414
This template is used to generate sandbox data API code.
1515
"""
1616

17-
from typing import Any, Dict, Optional
17+
from typing import Any, Dict, Optional, Tuple, Union
18+
19+
import httpx
1820

1921
from agentrun.utils.config import Config
2022
from agentrun.utils.data_api import DataAPI, ResourceType
23+
from agentrun.utils.exception import ClientError, ServerError
24+
from agentrun.utils.log import logger
2125

2226

2327
class SandboxDataAPI(DataAPI):
28+
_REQUEST_ID_HEADERS = (
29+
"x-acs-request-id",
30+
"x-agentrun-request-id",
31+
"x-request-id",
32+
"x-fc-request-id",
33+
)
2434

2535
def __init__(
2636
self,
@@ -73,6 +83,158 @@ def __refresh_access_token(
7383
self.auth(config=cfg)
7484
self.access_token_map[sandbox_id or template_name] = self.access_token
7585

86+
@classmethod
87+
def _extract_error_fields(
88+
cls, response: httpx.Response, response_body: Any
89+
) -> Tuple[Optional[str], Optional[str], str]:
90+
error_code = None
91+
request_id = None
92+
message = ""
93+
94+
if isinstance(response_body, dict):
95+
raw_code = response_body.get("code")
96+
if raw_code is not None:
97+
error_code = str(raw_code)
98+
99+
raw_request_id = response_body.get("requestId")
100+
if raw_request_id is not None:
101+
request_id = str(raw_request_id)
102+
103+
raw_message = response_body.get("message")
104+
if raw_message is not None:
105+
message = str(raw_message)
106+
elif isinstance(response_body, str):
107+
message = response_body.strip()
108+
109+
if request_id is None:
110+
for header in cls._REQUEST_ID_HEADERS:
111+
value = response.headers.get(header)
112+
if value:
113+
request_id = value
114+
break
115+
116+
if not message:
117+
message = (
118+
response.reason_phrase or f"HTTP {response.status_code} error"
119+
)
120+
121+
return error_code, request_id, message
122+
123+
@staticmethod
124+
def _parse_error_response_body(response: httpx.Response) -> Any:
125+
if not response.text:
126+
return {}
127+
try:
128+
return response.json()
129+
except ValueError:
130+
return response.text
131+
132+
@staticmethod
133+
def _parse_success_response(response: httpx.Response) -> Dict[str, Any]:
134+
if not response.text:
135+
return {}
136+
try:
137+
return response.json()
138+
except ValueError as e:
139+
error_msg = f"Failed to parse JSON response: {e}"
140+
logger.error(error_msg)
141+
raise ServerError(
142+
status_code=response.status_code,
143+
message=error_msg,
144+
response_body=response.text,
145+
response_headers=dict(response.headers),
146+
) from e
147+
148+
@classmethod
149+
def _raise_for_error_response(cls, response: httpx.Response) -> None:
150+
response_body = cls._parse_error_response_body(response)
151+
error_code, request_id, message = cls._extract_error_fields(
152+
response, response_body
153+
)
154+
if response.status_code >= 500:
155+
raise ServerError(
156+
status_code=response.status_code,
157+
message=message,
158+
request_id=request_id,
159+
error_code=error_code,
160+
response_body=response_body,
161+
response_headers=dict(response.headers),
162+
)
163+
raise ClientError(
164+
status_code=response.status_code,
165+
message=message,
166+
request_id=request_id,
167+
error_code=error_code,
168+
response_body=response_body,
169+
response_headers=dict(response.headers),
170+
)
171+
172+
async def _make_request_async(
173+
self,
174+
method: str,
175+
url: str,
176+
data: Optional[Union[Dict[str, Any], str]] = None,
177+
headers: Optional[Dict[str, str]] = None,
178+
query: Optional[Dict[str, Any]] = None,
179+
config: Optional[Config] = None,
180+
) -> Dict[str, Any]:
181+
method, url, req_headers, req_json, req_content = self._prepare_request(
182+
method, url, data, headers, query, config=config
183+
)
184+
185+
try:
186+
async with httpx.AsyncClient(
187+
timeout=self.config.get_timeout()
188+
) as client:
189+
response = await client.request(
190+
method,
191+
url,
192+
headers=req_headers,
193+
json=req_json,
194+
content=req_content,
195+
)
196+
logger.debug(f"Response: {response.text}")
197+
198+
if response.status_code >= 400:
199+
self._raise_for_error_response(response)
200+
201+
return self._parse_success_response(response)
202+
except httpx.RequestError as e:
203+
error_msg = f"Request error: {e!s}"
204+
raise ClientError(status_code=0, message=error_msg) from e
205+
206+
def _make_request(
207+
self,
208+
method: str,
209+
url: str,
210+
data: Optional[Union[Dict[str, Any], str]] = None,
211+
headers: Optional[Dict[str, str]] = None,
212+
query: Optional[Dict[str, Any]] = None,
213+
config: Optional[Config] = None,
214+
) -> Dict[str, Any]:
215+
method, url, req_headers, req_json, req_content = self._prepare_request(
216+
method, url, data, headers, query, config=config
217+
)
218+
219+
try:
220+
with httpx.Client(timeout=self.config.get_timeout()) as client:
221+
response = client.request(
222+
method,
223+
url,
224+
headers=req_headers,
225+
json=req_json,
226+
content=req_content,
227+
)
228+
logger.debug(f"Response: {response.text}")
229+
230+
if response.status_code >= 400:
231+
self._raise_for_error_response(response)
232+
233+
return self._parse_success_response(response)
234+
except httpx.RequestError as e:
235+
error_msg = f"Request error: {e!s}"
236+
raise ClientError(status_code=0, message=error_msg) from e
237+
76238
async def check_health_async(self):
77239
return await self.get_async("/health")
78240

0 commit comments

Comments
 (0)