diff --git a/src/band/client/rest.py b/src/band/client/rest.py index 2184f8e11..650c89b33 100644 --- a/src/band/client/rest.py +++ b/src/band/client/rest.py @@ -13,6 +13,13 @@ ) """ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from functools import wraps +from json.decoder import JSONDecodeError +from typing import Any + from band_rest import ( RestClient, AsyncRestClient, @@ -39,7 +46,9 @@ Peer, UnauthorizedError, ) +from band_rest.agent_api_identity.raw_client import AsyncRawAgentApiIdentityClient from band_rest.core import ParsingError +from band_rest.core.api_error import ApiError from band_rest.core.request_options import RequestOptions from band_rest.types import ChatMessageRequestMentionsItem @@ -48,6 +57,36 @@ # We set max_retries=3 to handle transient rate limit errors gracefully. DEFAULT_REQUEST_OPTIONS: RequestOptions = {"max_retries": 3} + +def _without_json_decode_chain( + method: Callable[..., Awaitable[Any]], +) -> Callable[..., Awaitable[Any]]: + """Re-raise Fern ``ApiError`` without a ``JSONDecodeError`` ``__context__``. + + ``band-client-rest==0.0.26`` (still in 0.0.28) calls ``_response.json()`` + for non-2xx statuses, then ``raise ApiError(...)`` on ``JSONDecodeError`` + without ``from None``. Empty ALB 429 bodies therefore print both + tracebacks. Drop this wrap when the pin raises with ``from None``. + """ + + @wraps(method) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await method(*args, **kwargs) + except ApiError as error: + if isinstance(error.__context__, JSONDecodeError): + raise error from None + raise + + return wrapper + + +setattr( + AsyncRawAgentApiIdentityClient, + "get_agent_me", + _without_json_decode_chain(AsyncRawAgentApiIdentityClient.get_agent_me), +) + __all__ = [ "RestClient", "AsyncRestClient", diff --git a/tests/platform/test_rest_empty_429.py b/tests/platform/test_rest_empty_429.py new file mode 100644 index 000000000..722090a05 --- /dev/null +++ b/tests/platform/test_rest_empty_429.py @@ -0,0 +1,43 @@ +"""Empty HTTP 429 bodies must raise a clean ApiError (GitHub #108). + +AWS ALB rate-limit responses often have ``Content-Length: 0``. The Fern +raw client still calls ``_response.json()`` for that status, then wraps +the resulting ``JSONDecodeError`` in ``ApiError`` without ``from None``. +Python therefore prints both tracebacks. + +Interception is the maintained ``pytest-httpx`` ``httpx_mock`` fixture on +the real ``BandLink`` REST client, matching ``test_link_credentials.py``. +``max_retries=0`` isolates the decode/chaining bug from Fern 429 retries. +""" + +from __future__ import annotations + +import traceback +from json.decoder import JSONDecodeError + +import pytest +from band_rest.core.api_error import ApiError +from pytest_httpx import HTTPXMock + +from band.platform.link import BandLink + + +async def test_empty_429_raises_api_error_without_json_decode_chain( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response(status_code=429, content=b"") + + link = BandLink(agent_id="agent-1", api_key="test-key") + with pytest.raises(ApiError) as exc_info: + await link.rest.agent_api_identity.get_agent_me( + request_options={"max_retries": 0}, + ) + + error = exc_info.value + formatted = "".join(traceback.format_exception(error)) + assert error.status_code == 429 + assert not isinstance(error.__cause__, JSONDecodeError) + assert error.__suppress_context__ or not isinstance( + error.__context__, JSONDecodeError + ), formatted + assert "JSONDecodeError" not in formatted