From c2598ccd62fb9de0561c7c837fb518f836200eba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 07:06:02 +0000 Subject: [PATCH 1/4] test: fail on JSONDecodeError chain from empty 429 (#108) Add a red-on-purpose pytest that hits get_agent_me through the real BandLink REST client with an ALB-style empty 429 body. Asserts the raised ApiError has no JSONDecodeError in __cause__/__context__ and no JSONDecodeError in the formatted traceback. Co-authored-by: Alexander Nikitin --- tests/platform/test_rest_empty_429.py | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/platform/test_rest_empty_429.py diff --git a/tests/platform/test_rest_empty_429.py b/tests/platform/test_rest_empty_429.py new file mode 100644 index 000000000..5865352a8 --- /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 + ) + assert "JSONDecodeError" not in formatted From 3e1bce399a7538c18ea1c15caed8e41d1f92dbdf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 07:06:48 +0000 Subject: [PATCH 2/4] test: include chained traceback in empty-429 assertion Surface the dual JSONDecodeError/ApiError traceback in the pytest failure message so the #108 symptom is visible in the red run. Co-authored-by: Alexander Nikitin --- tests/platform/test_rest_empty_429.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/platform/test_rest_empty_429.py b/tests/platform/test_rest_empty_429.py index 5865352a8..722090a05 100644 --- a/tests/platform/test_rest_empty_429.py +++ b/tests/platform/test_rest_empty_429.py @@ -39,5 +39,5 @@ async def test_empty_429_raises_api_error_without_json_decode_chain( assert not isinstance(error.__cause__, JSONDecodeError) assert error.__suppress_context__ or not isinstance( error.__context__, JSONDecodeError - ) + ), formatted assert "JSONDecodeError" not in formatted From 1a1f37b6d04a98a28c17220cbb0753e8b3936651 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 07:13:13 +0000 Subject: [PATCH 3/4] fix: suppress JSONDecodeError chain on empty HTTP 429 band-client-rest==0.0.26 (and 0.0.28) re-raises ApiError from JSONDecodeError without from None when get_agent_me sees an empty error body. Wrap that generated method so callers get a clean ApiError. Co-authored-by: Alexander Nikitin --- src/band/client/rest.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/band/client/rest.py b/src/band/client/rest.py index 2184f8e11..1c4794b1a 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,34 @@ # 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 + + +AsyncRawAgentApiIdentityClient.get_agent_me = _without_json_decode_chain( + AsyncRawAgentApiIdentityClient.get_agent_me +) + __all__ = [ "RestClient", "AsyncRestClient", From 0e7d7a0af8f2acaa7a03915bb95f6215db822508 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 07:13:48 +0000 Subject: [PATCH 4/4] fix: assign empty-429 wrap via setattr for typecheck Pyrefly rejects assigning a generic wrapper onto the generated get_agent_me signature; setattr keeps the same pin-tied wrap. Co-authored-by: Alexander Nikitin --- src/band/client/rest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/band/client/rest.py b/src/band/client/rest.py index 1c4794b1a..650c89b33 100644 --- a/src/band/client/rest.py +++ b/src/band/client/rest.py @@ -81,8 +81,10 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: return wrapper -AsyncRawAgentApiIdentityClient.get_agent_me = _without_json_decode_chain( - AsyncRawAgentApiIdentityClient.get_agent_me +setattr( + AsyncRawAgentApiIdentityClient, + "get_agent_me", + _without_json_decode_chain(AsyncRawAgentApiIdentityClient.get_agent_me), ) __all__ = [