From 32d66d48eb723a91083715bac4f4fc7aba2d7eee Mon Sep 17 00:00:00 2001 From: Isaac Francisco <78627776+isahers1@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:58:38 -0700 Subject: [PATCH] chore(sdk-py): type errors nicely (#6173) This PR types errors in a nicer way --- libs/sdk-py/langgraph_sdk/client.py | 121 ++------------- libs/sdk-py/langgraph_sdk/errors.py | 222 ++++++++++++++++++++++++++++ libs/sdk-py/tests/test_errors.py | 108 ++++++++++++++ 3 files changed, 343 insertions(+), 108 deletions(-) create mode 100644 libs/sdk-py/langgraph_sdk/errors.py create mode 100644 libs/sdk-py/tests/test_errors.py diff --git a/libs/sdk-py/langgraph_sdk/client.py b/libs/sdk-py/langgraph_sdk/client.py index 6b8bafa55..f08d7b1fc 100644 --- a/libs/sdk-py/langgraph_sdk/client.py +++ b/libs/sdk-py/langgraph_sdk/client.py @@ -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: diff --git a/libs/sdk-py/langgraph_sdk/errors.py b/libs/sdk-py/langgraph_sdk/errors.py new file mode 100644 index 000000000..3b793cd68 --- /dev/null +++ b/libs/sdk-py/langgraph_sdk/errors.py @@ -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 diff --git a/libs/sdk-py/tests/test_errors.py b/libs/sdk-py/tests/test_errors.py new file mode 100644 index 000000000..9238cb1fd --- /dev/null +++ b/libs/sdk-py/tests/test_errors.py @@ -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"