Skip to content

Commit 14361d0

Browse files
authored
fix(sdk): improve API response typing and error handling (#532)
* fix(sdk): improve API response typing and error handling in Python and TS SDKs - request_binary: parse JSON error body on failure for detailed error messages - flush(): use FlagResponse type instead of untyped dict/unsafe cast - patch_message_meta/patch_configs: remove type:ignore and unsafe as casts - project configs: add ProjectConfig Pydantic model and Zod schema - download_to_sandbox: add DownloadToSandboxResp model instead of ad-hoc extraction * fix(sdk-py): remove unused Any import in project.py * fix(sdk): add type guards for error field and flush response validation - Add isinstance/typeof checks for error field in Python and TS error parsing - Add isinstance guard before FlagResponse.model_validate in flush() * fix(sdk): address code review issues in response handling and type safety - Add response guard in TS flush() to handle non-JSON responses gracefully - Replace unsafe `as` cast with type guard for payload.error in TS client - Fix TS getMessages timeDesc default to null to match Python SDK behavior - Initialize parsed variable in Python sync client to match async client
1 parent 4fadfa9 commit 14361d0

18 files changed

Lines changed: 209 additions & 49 deletions

File tree

src/client/acontext-py/src/acontext/async_client.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,8 @@ def _handle_response(response: httpx.Response, *, unwrap: bool) -> Any:
197197
error: str | None = None
198198
if payload and isinstance(payload, Mapping):
199199
message = str(payload.get("msg") or payload.get("message") or message)
200-
error = payload.get("error")
200+
error_val = payload.get("error")
201+
error = error_val if isinstance(error_val, str) else None
201202
try:
202203
code_val = payload.get("code")
203204
if isinstance(code_val, int):
@@ -269,9 +270,31 @@ async def request_binary(
269270
raise TransportError(str(exc)) from exc
270271

271272
if response.status_code >= 400:
273+
# Try to parse JSON error body for detailed error info
274+
# (API returns JSON errors even for binary endpoints)
275+
content_type = response.headers.get("content-type", "")
276+
message = response.reason_phrase
277+
code: int | None = None
278+
error: str | None = None
279+
payload: Mapping[str, Any] | None = None
280+
if "application/json" in content_type:
281+
try:
282+
parsed = response.json()
283+
if isinstance(parsed, Mapping):
284+
payload = parsed
285+
message = str(parsed.get("msg") or parsed.get("message") or message)
286+
error = parsed.get("error")
287+
code_val = parsed.get("code")
288+
if isinstance(code_val, int):
289+
code = code_val
290+
except ValueError:
291+
pass
272292
raise APIError(
273293
status_code=response.status_code,
274-
message=response.reason_phrase,
294+
code=code,
295+
message=message,
296+
error=error,
297+
payload=payload,
275298
)
276299

277300
# Return raw bytes without decoding

src/client/acontext-py/src/acontext/client.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def request(
180180
def _handle_response(response: httpx.Response, *, unwrap: bool) -> Any:
181181
content_type = response.headers.get("content-type", "")
182182

183-
parsed: Mapping[str, Any] | None
183+
parsed: Mapping[str, Any] | None = None
184184
if "application/json" in content_type:
185185
try:
186186
parsed = response.json() # dict
@@ -196,7 +196,8 @@ def _handle_response(response: httpx.Response, *, unwrap: bool) -> Any:
196196
error: str | None = None
197197
if payload and isinstance(payload, Mapping):
198198
message = str(payload.get("msg") or payload.get("message") or message)
199-
error = payload.get("error")
199+
error_val = payload.get("error")
200+
error = error_val if isinstance(error_val, str) else None
200201
try:
201202
code_val = payload.get("code")
202203
if isinstance(code_val, int):
@@ -268,9 +269,31 @@ def request_binary(
268269
raise TransportError(str(exc)) from exc
269270

270271
if response.status_code >= 400:
272+
# Try to parse JSON error body for detailed error info
273+
# (API returns JSON errors even for binary endpoints)
274+
content_type = response.headers.get("content-type", "")
275+
message = response.reason_phrase
276+
code: int | None = None
277+
error: str | None = None
278+
payload: Mapping[str, Any] | None = None
279+
if "application/json" in content_type:
280+
try:
281+
parsed = response.json()
282+
if isinstance(parsed, Mapping):
283+
payload = parsed
284+
message = str(parsed.get("msg") or parsed.get("message") or message)
285+
error = parsed.get("error")
286+
code_val = parsed.get("code")
287+
if isinstance(code_val, int):
288+
code = code_val
289+
except ValueError:
290+
pass
271291
raise APIError(
272292
status_code=response.status_code,
273-
message=response.reason_phrase,
293+
code=code,
294+
message=message,
295+
error=error,
296+
payload=payload,
274297
)
275298

276299
# Return raw bytes without decoding

src/client/acontext-py/src/acontext/resources/async_disks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ..types.disk import (
1414
Artifact,
1515
Disk,
16+
DownloadToSandboxResp,
1617
GetArtifactResp,
1718
ListArtifactsResp,
1819
ListDisksOutput,
@@ -332,7 +333,7 @@ async def download_to_sandbox(
332333
f"/disk/{disk_id}/artifact/download_to_sandbox",
333334
json_data=payload,
334335
)
335-
return bool(data.get("success", False))
336+
return DownloadToSandboxResp.model_validate(data).success
336337

337338
async def upload_from_sandbox(
338339
self,

src/client/acontext-py/src/acontext/resources/async_project.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,37 @@
55
from typing import Any
66

77
from ..client_types import AsyncRequesterProtocol
8+
from ..types.project import ProjectConfig
89

910

1011
class AsyncProjectAPI:
1112
def __init__(self, requester: AsyncRequesterProtocol) -> None:
1213
self._requester = requester
1314

14-
async def get_configs(self) -> dict[str, Any]:
15+
async def get_configs(self) -> ProjectConfig:
1516
"""Get the project-level configuration.
1617
1718
Returns:
18-
Dictionary containing the current project configuration.
19+
ProjectConfig containing the current project configuration.
1920
"""
2021
data = await self._requester.request("GET", "/project/configs")
21-
return data if isinstance(data, dict) else {}
22+
if isinstance(data, dict):
23+
return ProjectConfig.model_validate(data)
24+
return ProjectConfig()
2225

23-
async def update_configs(self, configs: dict[str, Any]) -> dict[str, Any]:
26+
async def update_configs(self, configs: dict[str, Any]) -> ProjectConfig:
2427
"""Update the project-level configuration by merging keys.
2528
Keys with None/null values are deleted (reset to default).
2629
2730
Args:
2831
configs: Dictionary of configuration keys to merge.
2932
3033
Returns:
31-
Dictionary containing the updated project configuration.
34+
ProjectConfig containing the updated project configuration.
3235
"""
3336
data = await self._requester.request(
3437
"PATCH", "/project/configs", json_data=configs
3538
)
36-
return data if isinstance(data, dict) else {}
39+
if isinstance(data, dict):
40+
return ProjectConfig.model_validate(data)
41+
return ProjectConfig()

src/client/acontext-py/src/acontext/resources/async_sessions.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .._utils import build_params, validate_edit_strategies
99
from ..client_types import AsyncRequesterProtocol
1010
from ..messages import AcontextMessage
11+
from ..types.common import FlagResponse
1112
from ..types.session import (
1213
EditStrategy,
1314
CopySessionResult,
@@ -427,17 +428,19 @@ async def get_messages(
427428
)
428429
return GetMessagesOutput.model_validate(data)
429430

430-
async def flush(self, session_id: str) -> dict[str, Any]:
431+
async def flush(self, session_id: str) -> FlagResponse:
431432
"""Flush the session buffer for a given session.
432433
433434
Args:
434435
session_id: The UUID of the session.
435436
436437
Returns:
437-
Dictionary containing status and errmsg fields.
438+
FlagResponse containing status and errmsg fields.
438439
"""
439440
data = await self._requester.request("POST", f"/session/{session_id}/flush")
440-
return data # type: ignore
441+
if isinstance(data, dict):
442+
return FlagResponse.model_validate(data)
443+
return FlagResponse(status=0, errmsg="")
441444

442445
async def get_token_counts(self, session_id: str) -> TokenCounts:
443446
"""Get total token counts for all text and tool-call parts in a session.
@@ -512,7 +515,9 @@ async def patch_message_meta(
512515
f"/session/{session_id}/messages/{message_id}/meta",
513516
json_data=payload,
514517
)
515-
return data.get("meta", {}) # type: ignore
518+
if isinstance(data, dict):
519+
return data.get("meta", {})
520+
return {}
516521

517522
async def patch_configs(
518523
self,
@@ -551,7 +556,9 @@ async def patch_configs(
551556
f"/session/{session_id}/configs",
552557
json_data=payload,
553558
)
554-
return data.get("configs", {}) # type: ignore
559+
if isinstance(data, dict):
560+
return data.get("configs", {})
561+
return {}
555562

556563
async def copy(self, session_id: str) -> CopySessionResult:
557564
"""Copy (duplicate) a session with all its messages and tasks.

src/client/acontext-py/src/acontext/resources/disks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ..types.disk import (
1616
Artifact,
1717
Disk,
18+
DownloadToSandboxResp,
1819
GetArtifactResp,
1920
ListArtifactsResp,
2021
ListDisksOutput,
@@ -332,7 +333,7 @@ def download_to_sandbox(
332333
f"/disk/{disk_id}/artifact/download_to_sandbox",
333334
json_data=payload,
334335
)
335-
return bool(data.get("success", False))
336+
return DownloadToSandboxResp.model_validate(data).success
336337

337338
def upload_from_sandbox(
338339
self,

src/client/acontext-py/src/acontext/resources/project.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,37 @@
55
from typing import Any
66

77
from ..client_types import RequesterProtocol
8+
from ..types.project import ProjectConfig
89

910

1011
class ProjectAPI:
1112
def __init__(self, requester: RequesterProtocol) -> None:
1213
self._requester = requester
1314

14-
def get_configs(self) -> dict[str, Any]:
15+
def get_configs(self) -> ProjectConfig:
1516
"""Get the project-level configuration.
1617
1718
Returns:
18-
Dictionary containing the current project configuration.
19+
ProjectConfig containing the current project configuration.
1920
"""
2021
data = self._requester.request("GET", "/project/configs")
21-
return data if isinstance(data, dict) else {}
22+
if isinstance(data, dict):
23+
return ProjectConfig.model_validate(data)
24+
return ProjectConfig()
2225

23-
def update_configs(self, configs: dict[str, Any]) -> dict[str, Any]:
26+
def update_configs(self, configs: dict[str, Any]) -> ProjectConfig:
2427
"""Update the project-level configuration by merging keys.
2528
Keys with None/null values are deleted (reset to default).
2629
2730
Args:
2831
configs: Dictionary of configuration keys to merge.
2932
3033
Returns:
31-
Dictionary containing the updated project configuration.
34+
ProjectConfig containing the updated project configuration.
3235
"""
3336
data = self._requester.request(
3437
"PATCH", "/project/configs", json_data=configs
3538
)
36-
return data if isinstance(data, dict) else {}
39+
if isinstance(data, dict):
40+
return ProjectConfig.model_validate(data)
41+
return ProjectConfig()

src/client/acontext-py/src/acontext/resources/sessions.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .._utils import build_params, validate_edit_strategies
99
from ..client_types import RequesterProtocol
1010
from ..messages import AcontextMessage
11+
from ..types.common import FlagResponse
1112
from ..types.session import (
1213
EditStrategy,
1314
CopySessionResult,
@@ -427,17 +428,19 @@ def get_messages(
427428
)
428429
return GetMessagesOutput.model_validate(data)
429430

430-
def flush(self, session_id: str) -> dict[str, Any]:
431+
def flush(self, session_id: str) -> FlagResponse:
431432
"""Flush the session buffer for a given session.
432433
433434
Args:
434435
session_id: The UUID of the session.
435436
436437
Returns:
437-
Dictionary containing status and errmsg fields.
438+
FlagResponse containing status and errmsg fields.
438439
"""
439440
data = self._requester.request("POST", f"/session/{session_id}/flush")
440-
return data # type: ignore
441+
if isinstance(data, dict):
442+
return FlagResponse.model_validate(data)
443+
return FlagResponse(status=0, errmsg="")
441444

442445
def get_token_counts(self, session_id: str) -> TokenCounts:
443446
"""Get total token counts for all text and tool-call parts in a session.
@@ -506,7 +509,9 @@ def patch_message_meta(
506509
f"/session/{session_id}/messages/{message_id}/meta",
507510
json_data=payload,
508511
)
509-
return data.get("meta", {}) # type: ignore
512+
if isinstance(data, dict):
513+
return data.get("meta", {})
514+
return {}
510515

511516
def patch_configs(
512517
self,
@@ -545,7 +550,9 @@ def patch_configs(
545550
f"/session/{session_id}/configs",
546551
json_data=payload,
547552
)
548-
return data.get("configs", {}) # type: ignore
553+
if isinstance(data, dict):
554+
return data.get("configs", {})
555+
return {}
549556

550557
def copy(self, session_id: str) -> CopySessionResult:
551558
"""Copy (duplicate) a session with all its messages and tasks.

src/client/acontext-py/src/acontext/types/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .disk import (
55
Artifact,
66
Disk,
7+
DownloadToSandboxResp,
78
GetArtifactResp,
89
ListArtifactsResp,
910
ListDisksOutput,
@@ -44,6 +45,7 @@
4445
User,
4546
UserResourceCounts,
4647
)
48+
from .project import ProjectConfig
4749
from .learning_space import (
4850
LearningSpace,
4951
LearningSpaceSession,
@@ -58,6 +60,7 @@
5860
# Disk types
5961
"Artifact",
6062
"Disk",
63+
"DownloadToSandboxResp",
6164
"GetArtifactResp",
6265
"ListArtifactsResp",
6366
"ListDisksOutput",
@@ -93,6 +96,8 @@
9396
"ListUsersOutput",
9497
"User",
9598
"UserResourceCounts",
99+
# Project types
100+
"ProjectConfig",
96101
# Learning space types
97102
"LearningSpace",
98103
"LearningSpaceSession",

src/client/acontext-py/src/acontext/types/disk.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,9 @@ class UpdateArtifactResp(BaseModel):
6262
"""Response model for updating an artifact."""
6363

6464
artifact: Artifact = Field(..., description="Updated artifact information")
65+
66+
67+
class DownloadToSandboxResp(BaseModel):
68+
"""Response model for downloading an artifact to a sandbox."""
69+
70+
success: bool = Field(..., description="Whether the download was successful")

0 commit comments

Comments
 (0)