Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/band/client/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions tests/platform/test_rest_empty_429.py
Original file line number Diff line number Diff line change
@@ -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