Skip to content
Open
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
57 changes: 57 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Release Notes

## Unreleased

### Breaking Changes

- Sandbox data-plane HTTP JSON APIs now follow standard HTTP error handling:
- `2xx` responses return the business response body.
- `4xx` responses raise `ClientError`.
- `5xx` responses raise `ServerError`.
- Error responses such as `{"code": "...", "requestId": "...", "message": "..."}`
are no longer returned as normal dictionaries for Sandbox HTTP JSON APIs. The
fields are exposed on the raised exception as `error_code`, `request_id`, and
`message`.
- Existing code that checked returned dictionaries for `code` and `requestId`
must migrate to `try` / `except ClientError` / `except ServerError`.
- `HTTPError.__str__()` output format has changed. The old format unconditionally
included `"Request ID: None. Details: {}"` even when those fields were empty.
The new format only includes non-empty fields and uses `". "` as separator.
Code that parses this string representation (e.g. log parsers or test assertions
on `str(error)`) must be updated.

### Migration

Before:

```python
resp = ci.cmd(command="echo hello", cwd="/tmp", timeout=30)
if "code" in resp and "requestId" in resp:
raise RuntimeError(resp["message"])
```

After:

```python
from agentrun.utils.exception import ClientError, ServerError

try:
resp = ci.cmd(command="echo hello", cwd="/tmp", timeout=30)
except ClientError as e:
print(e.status_code, e.error_code, e.request_id, e.message)
raise
except ServerError as e:
print(e.status_code, e.error_code, e.request_id, e.message)
raise
```

Command execution failures are still business-level failures and should be
handled by checking `resp["result"]["exitCode"]` after a successful HTTP
response.

### Scope

This change is intentionally limited to Sandbox data-plane HTTP JSON APIs. It
does not change WebSocket/CDP/VNC URL generation, Playwright connections, file
upload/download helpers, video download helpers, or non-Sandbox data-plane
clients.
132 changes: 131 additions & 1 deletion agentrun/sandbox/api/__sandbox_data_async_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@
This template is used to generate sandbox data API code.
"""

from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Tuple, Union

import httpx

from agentrun.utils.config import Config
from agentrun.utils.data_api import DataAPI, ResourceType
from agentrun.utils.exception import ClientError, ServerError
from agentrun.utils.log import logger


class SandboxDataAPI(DataAPI):
_REQUEST_ID_HEADERS = (
"x-acs-request-id",
"x-agentrun-request-id",
"x-request-id",
"x-fc-request-id",
)

def __init__(
self,
Expand Down Expand Up @@ -63,6 +73,126 @@ def __refresh_access_token(
self.auth(config=cfg)
self.access_token_map[sandbox_id or template_name] = self.access_token

@classmethod
def _extract_error_fields(
cls, response: httpx.Response, response_body: Any
) -> Tuple[Optional[str], Optional[str], str]:
error_code = None
request_id = None
message = ""

if isinstance(response_body, dict):
raw_code = response_body.get("code")
if raw_code is not None:
error_code = str(raw_code)

raw_request_id = response_body.get("requestId")
if raw_request_id is not None:
request_id = str(raw_request_id)

raw_message = response_body.get("message")
if raw_message is not None:
message = str(raw_message)
elif isinstance(response_body, str):
message = response_body.strip()

if request_id is None:
for header in cls._REQUEST_ID_HEADERS:
value = response.headers.get(header)
if value:
request_id = value
break

if not message:
message = (
response.reason_phrase or f"HTTP {response.status_code} error"
)

return error_code, request_id, message

@staticmethod
def _parse_error_response_body(response: httpx.Response) -> Any:
if not response.text:
return {}
try:
return response.json()
except ValueError:
return response.text

@staticmethod
def _parse_success_response(response: httpx.Response) -> Dict[str, Any]:
if not response.text:
return {}
try:
return response.json()
except ValueError as e:
error_msg = f"Failed to parse JSON response: {e}"
logger.error(error_msg)
raise ClientError(
status_code=response.status_code,
message=error_msg,
response_body=response.text,
response_headers=dict(response.headers),
) from e

@classmethod
def _raise_for_error_response(cls, response: httpx.Response) -> None:
response_body = cls._parse_error_response_body(response)
error_code, request_id, message = cls._extract_error_fields(
response, response_body
)
if response.status_code >= 500:
raise ServerError(
status_code=response.status_code,
message=message,
request_id=request_id,
error_code=error_code,
response_body=response_body,
response_headers=dict(response.headers),
)
raise ClientError(
status_code=response.status_code,
message=message,
request_id=request_id,
error_code=error_code,
response_body=response_body,
response_headers=dict(response.headers),
)

async def _make_request_async(
self,
method: str,
url: str,
data: Optional[Union[Dict[str, Any], str]] = None,
headers: Optional[Dict[str, str]] = None,
query: Optional[Dict[str, Any]] = None,
config: Optional[Config] = None,
) -> Dict[str, Any]:
method, url, req_headers, req_json, req_content = self._prepare_request(
method, url, data, headers, query, config=config
)

try:
async with httpx.AsyncClient(
timeout=self.config.get_timeout()
) as client:
response = await client.request(
method,
url,
headers=req_headers,
json=req_json,
content=req_content,
)
logger.debug(f"Response: {response.text}")

if response.status_code >= 400:
self._raise_for_error_response(response)

return self._parse_success_response(response)
except httpx.RequestError as e:
error_msg = f"Request error: {e!s}"
raise ClientError(status_code=0, message=error_msg) from e

async def check_health_async(self):
return await self.get_async("/health")

Expand Down
164 changes: 163 additions & 1 deletion agentrun/sandbox/api/sandbox_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,23 @@
This template is used to generate sandbox data API code.
"""

from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Tuple, Union

import httpx

from agentrun.utils.config import Config
from agentrun.utils.data_api import DataAPI, ResourceType
from agentrun.utils.exception import ClientError, ServerError
from agentrun.utils.log import logger


class SandboxDataAPI(DataAPI):
_REQUEST_ID_HEADERS = (
"x-acs-request-id",
"x-agentrun-request-id",
"x-request-id",
"x-fc-request-id",
)

def __init__(
self,
Expand Down Expand Up @@ -73,6 +83,158 @@ def __refresh_access_token(
self.auth(config=cfg)
self.access_token_map[sandbox_id or template_name] = self.access_token

@classmethod
def _extract_error_fields(
cls, response: httpx.Response, response_body: Any
) -> Tuple[Optional[str], Optional[str], str]:
error_code = None
request_id = None
message = ""

if isinstance(response_body, dict):
raw_code = response_body.get("code")
if raw_code is not None:
error_code = str(raw_code)

raw_request_id = response_body.get("requestId")
if raw_request_id is not None:
request_id = str(raw_request_id)

raw_message = response_body.get("message")
if raw_message is not None:
message = str(raw_message)
elif isinstance(response_body, str):
message = response_body.strip()

if request_id is None:
for header in cls._REQUEST_ID_HEADERS:
value = response.headers.get(header)
if value:
request_id = value
break

if not message:
message = (
response.reason_phrase or f"HTTP {response.status_code} error"
)

return error_code, request_id, message

@staticmethod
def _parse_error_response_body(response: httpx.Response) -> Any:
if not response.text:
return {}
try:
return response.json()
except ValueError:
return response.text

@staticmethod
def _parse_success_response(response: httpx.Response) -> Dict[str, Any]:
if not response.text:
return {}
try:
return response.json()
except ValueError as e:
error_msg = f"Failed to parse JSON response: {e}"
logger.error(error_msg)
raise ClientError(
status_code=response.status_code,
message=error_msg,
response_body=response.text,
response_headers=dict(response.headers),
) from e

@classmethod
def _raise_for_error_response(cls, response: httpx.Response) -> None:
response_body = cls._parse_error_response_body(response)
error_code, request_id, message = cls._extract_error_fields(
response, response_body
)
if response.status_code >= 500:
raise ServerError(
status_code=response.status_code,
message=message,
request_id=request_id,
error_code=error_code,
response_body=response_body,
response_headers=dict(response.headers),
)
raise ClientError(
status_code=response.status_code,
message=message,
request_id=request_id,
error_code=error_code,
response_body=response_body,
response_headers=dict(response.headers),
)

async def _make_request_async(
self,
method: str,
url: str,
data: Optional[Union[Dict[str, Any], str]] = None,
headers: Optional[Dict[str, str]] = None,
query: Optional[Dict[str, Any]] = None,
config: Optional[Config] = None,
) -> Dict[str, Any]:
method, url, req_headers, req_json, req_content = self._prepare_request(
method, url, data, headers, query, config=config
)

try:
async with httpx.AsyncClient(
timeout=self.config.get_timeout()
) as client:
response = await client.request(
method,
url,
headers=req_headers,
json=req_json,
content=req_content,
)
logger.debug(f"Response: {response.text}")

if response.status_code >= 400:
self._raise_for_error_response(response)

return self._parse_success_response(response)
except httpx.RequestError as e:
error_msg = f"Request error: {e!s}"
raise ClientError(status_code=0, message=error_msg) from e

def _make_request(
self,
method: str,
url: str,
data: Optional[Union[Dict[str, Any], str]] = None,
headers: Optional[Dict[str, str]] = None,
query: Optional[Dict[str, Any]] = None,
config: Optional[Config] = None,
) -> Dict[str, Any]:
method, url, req_headers, req_json, req_content = self._prepare_request(
method, url, data, headers, query, config=config
)

try:
with httpx.Client(timeout=self.config.get_timeout()) as client:
response = client.request(
method,
url,
headers=req_headers,
json=req_json,
content=req_content,
)
logger.debug(f"Response: {response.text}")

if response.status_code >= 400:
self._raise_for_error_response(response)

return self._parse_success_response(response)
except httpx.RequestError as e:
error_msg = f"Request error: {e!s}"
raise ClientError(status_code=0, message=error_msg) from e

async def check_health_async(self):
return await self.get_async("/health")

Expand Down
Loading
Loading