chore(sdk-py): type errors nicely (#6173)

This PR types errors in a nicer way
This commit is contained in:
Isaac Francisco
2025-09-24 12:58:38 -04:00
committed by GitHub
parent d933d455ec
commit 32d66d48eb
3 changed files with 343 additions and 108 deletions
+13 -108
View File
@@ -31,6 +31,7 @@ import httpx
import orjson
import langgraph_sdk
from langgraph_sdk.errors import _araise_for_status_typed, _raise_for_status_typed
from langgraph_sdk.schema import (
All,
Assistant,
@@ -310,15 +311,7 @@ class HttpClient:
r = await self.client.get(path, params=params, headers=headers)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await r.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def post(
@@ -343,15 +336,7 @@ class HttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await r.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def put(
@@ -372,15 +357,7 @@ class HttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await r.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def patch(
@@ -401,15 +378,7 @@ class HttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await r.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
await _araise_for_status_typed(r)
return await _adecode_json(r)
async def delete(
@@ -427,15 +396,7 @@ class HttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await r.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
await _araise_for_status_typed(r)
async def request_reconnect(
self,
@@ -536,15 +497,7 @@ class HttpClient:
if reconnect_path is None and on_response:
on_response(res)
# check status
try:
res.raise_for_status()
except httpx.HTTPStatusError as e:
body = (await res.aread()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
await _araise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
@@ -3622,15 +3575,7 @@ class SyncHttpClient:
r = self.client.get(path, params=params, headers=headers)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = r.read().decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
_raise_for_status_typed(r)
return _decode_json(r)
def post(
@@ -3654,15 +3599,7 @@ class SyncHttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = r.read().decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
_raise_for_status_typed(r)
return _decode_json(r)
def put(
@@ -3684,15 +3621,7 @@ class SyncHttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = r.read().decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
_raise_for_status_typed(r)
return _decode_json(r)
def patch(
@@ -3713,15 +3642,7 @@ class SyncHttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = r.read().decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
_raise_for_status_typed(r)
return _decode_json(r)
def delete(
@@ -3739,15 +3660,7 @@ class SyncHttpClient:
)
if on_response:
on_response(r)
try:
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = r.read().decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
_raise_for_status_typed(r)
def request_reconnect(
self,
@@ -3850,15 +3763,7 @@ class SyncHttpClient:
if reconnect_path is None and on_response:
on_response(res)
# check status
try:
res.raise_for_status()
except httpx.HTTPStatusError as e:
body = (res.read()).decode()
if sys.version_info >= (3, 11):
e.add_note(body)
else:
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
raise e
_raise_for_status_typed(res)
# check content type
content_type = res.headers.get("content-type", "").partition(";")[0]
if "text/event-stream" not in content_type:
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import logging
import sys
from typing import Any, Literal, cast
import httpx
import orjson
logger = logging.getLogger(__name__)
class LangGraphError(Exception):
pass
class APIError(httpx.HTTPStatusError, LangGraphError):
message: str
request: httpx.Request
body: object | None
code: str | None
param: str | None
type: str | None
def __init__(
self, message: str, response: httpx.Response, *, body: object | None
) -> None:
httpx.HTTPStatusError.__init__(
self, message, request=response.request, response=response
)
LangGraphError.__init__(self)
self.request = response.request
self.message = message
self.body = body
if isinstance(body, dict):
b = cast(dict[str, Any], body)
# Best-effort extraction of common fields if present
code_val = b.get("code")
self.code = code_val if isinstance(code_val, str) else None
param_val = b.get("param")
self.param = param_val if isinstance(param_val, str) else None
t = b.get("type")
self.type = t if isinstance(t, str) else None
else:
self.code = None
self.param = None
self.type = None
class APIResponseValidationError(APIError):
response: httpx.Response
status_code: int
def __init__(
self,
response: httpx.Response,
body: object | None,
*,
message: str | None = None,
) -> None:
super().__init__(
message or "Data returned by API invalid for expected schema.",
response,
body=body,
)
self.response = response
self.status_code = response.status_code
class APIStatusError(APIError):
response: httpx.Response
status_code: int
request_id: str | None
def __init__(
self, message: str, *, response: httpx.Response, body: object | None
) -> None:
super().__init__(message, response, body=body)
self.response = response
self.status_code = response.status_code
self.request_id = response.headers.get("x-request-id")
class APIConnectionError(APIError):
def __init__(
self, *, message: str = "Connection error.", request: httpx.Request
) -> None:
super().__init__(message, request, body=None)
class APITimeoutError(APIConnectionError):
def __init__(self, request: httpx.Request) -> None:
super().__init__(message="Request timed out.", request=request)
class BadRequestError(APIStatusError):
status_code: Literal[400] = 400
class AuthenticationError(APIStatusError):
status_code: Literal[401] = 401
class PermissionDeniedError(APIStatusError):
status_code: Literal[403] = 403
class NotFoundError(APIStatusError):
status_code: Literal[404] = 404
class ConflictError(APIStatusError):
status_code: Literal[409] = 409
class UnprocessableEntityError(APIStatusError):
status_code: Literal[422] = 422
class RateLimitError(APIStatusError):
status_code: Literal[429] = 429
class InternalServerError(APIStatusError):
pass
def _extract_error_message(body: object | None, fallback: str) -> str:
if isinstance(body, dict):
b = cast(dict[str, Any], body)
for key in ("message", "detail", "error"):
val = b.get(key)
if isinstance(val, str) and val:
return val
# Sometimes errors are structured like {"error": {"message": "..."}}
err = b.get("error")
if isinstance(err, dict):
e = cast(dict[str, Any], err)
for key in ("message", "detail"):
val = e.get(key)
if isinstance(val, str) and val:
return val
return fallback
async def _adecode_error_body(r: httpx.Response) -> object | None:
try:
data = await r.aread()
except Exception:
return None
if not data:
return None
try:
return orjson.loads(data)
except Exception:
try:
return data.decode()
except Exception:
return None
def _decode_error_body(r: httpx.Response) -> object | None:
try:
data = r.read()
except Exception:
return None
if not data:
return None
try:
return orjson.loads(data)
except Exception:
try:
return data.decode()
except Exception:
return None
def _map_status_error(response: httpx.Response, body: object | None) -> APIStatusError:
status = response.status_code
reason = response.reason_phrase or "HTTP Error"
message = _extract_error_message(body, f"{status} {reason}")
if status == 400:
return BadRequestError(message, response=response, body=body)
if status == 401:
return AuthenticationError(message, response=response, body=body)
if status == 403:
return PermissionDeniedError(message, response=response, body=body)
if status == 404:
return NotFoundError(message, response=response, body=body)
if status == 409:
return ConflictError(message, response=response, body=body)
if status == 422:
return UnprocessableEntityError(message, response=response, body=body)
if status == 429:
return RateLimitError(message, response=response, body=body)
if 500 <= status:
return InternalServerError(message, response=response, body=body)
return APIStatusError(message, response=response, body=body)
async def _araise_for_status_typed(r: httpx.Response) -> None:
if r.status_code < 400:
return
body = await _adecode_error_body(r)
err = _map_status_error(r, body)
# Log for older Python versions without Exception notes
if not (sys.version_info >= (3, 11)):
logger.error(f"Error from langgraph-api: {getattr(err, 'message', '')}")
raise err
def _raise_for_status_typed(r: httpx.Response) -> None:
if r.status_code < 400:
return
body = _decode_error_body(r)
err = _map_status_error(r, body)
if not (sys.version_info >= (3, 11)):
logger.error(f"Error from langgraph-api: {getattr(err, 'message', '')}")
raise err
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import cast
import httpx
import orjson
import pytest
from langgraph_sdk.errors import (
APIStatusError,
AuthenticationError,
BadRequestError,
ConflictError,
InternalServerError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
UnprocessableEntityError,
_raise_for_status_typed,
)
def make_response(
status: int,
*,
json_body: dict | None = None,
text_body: str | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
request = httpx.Request("GET", "https://example.com/test")
content: bytes | None
if json_body is not None:
content = orjson.dumps(json_body)
elif text_body is not None:
content = text_body.encode()
else:
content = b""
return httpx.Response(
status, headers=headers or {}, content=content, request=request
)
@pytest.mark.parametrize(
"status,exc_type",
[
(400, BadRequestError),
(401, AuthenticationError),
(403, PermissionDeniedError),
(404, NotFoundError),
(409, ConflictError),
(422, UnprocessableEntityError),
(429, RateLimitError),
(500, InternalServerError),
(503, InternalServerError), # any 5xx
(418, APIStatusError), # unmapped 4xx falls back to base type
],
)
def test_raise_for_status_typed_maps_exceptions_and_sets_status_code(
status: int, exc_type: type[APIStatusError]
) -> None:
r = make_response(
status, json_body={"message": "boom", "code": "abc", "param": "p", "type": "t"}
)
with pytest.raises(exc_type) as ei:
_raise_for_status_typed(r)
err = cast(APIStatusError, ei.value)
assert err.status_code == status
# response attribute should be present and match
assert err.response.status_code == status
def test_request_id_is_extracted_when_present() -> None:
r = make_response(
404, json_body={"detail": "missing"}, headers={"x-request-id": "req-123"}
)
with pytest.raises(NotFoundError) as ei:
_raise_for_status_typed(r)
err = cast(APIStatusError, ei.value)
# request_id only exists on APIStatusError subclasses
assert err.request_id == "req-123"
def test_non_json_body_does_not_break_mapping() -> None:
r = make_response(429, text_body="Too many requests")
with pytest.raises(RateLimitError) as ei:
_raise_for_status_typed(r)
err = cast(APIStatusError, ei.value)
assert err.status_code == 429
def test_field_extraction_from_json_body() -> None:
r = make_response(
400,
json_body={
"message": "Invalid parameter",
"code": "invalid_param",
"param": "limit",
"type": "invalid_request_error",
},
)
with pytest.raises(BadRequestError) as ei:
_raise_for_status_typed(r)
err = cast(APIStatusError, ei.value)
assert err.code == "invalid_param"
assert err.param == "limit"
assert err.type == "invalid_request_error"