From 0e26e33c9d99f91563aabb5e2c75e1fe932c0a9b Mon Sep 17 00:00:00 2001 From: Maxwell Calkin Date: Sun, 8 Mar 2026 12:35:58 -0400 Subject: [PATCH] fix: handle non-JSON-serializable objects in TogetherException repr Add `default=str` to `json.dumps()` in `TogetherException.__repr__()` so that non-serializable objects (like aiohttp's CIMultiDictProxy headers) fall back to their string representation instead of raising TypeError. Fixes #108 Co-Authored-By: Claude Opus 4.6 --- src/together/error.py | 3 +- tests/unit/test_exception_repr.py | 109 ++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_exception_repr.py diff --git a/src/together/error.py b/src/together/error.py index e2883a2c..4e80cbd9 100644 --- a/src/together/error.py +++ b/src/together/error.py @@ -44,7 +44,8 @@ def __repr__(self) -> str: "status": self.http_status, "request_id": self.request_id, "headers": self.headers, - } + }, + default=str, ) return "%s(%r)" % (self.__class__.__name__, repr_message) diff --git a/tests/unit/test_exception_repr.py b/tests/unit/test_exception_repr.py new file mode 100644 index 00000000..d10205c2 --- /dev/null +++ b/tests/unit/test_exception_repr.py @@ -0,0 +1,109 @@ +"""Tests for TogetherException.__repr__ with non-JSON-serializable objects. + +Regression tests for https://github.com/togethercomputer/together-python/issues/108 +where repr() on a TogetherException crashed with TypeError when headers +contained non-serializable objects like aiohttp's CIMultiDictProxy. +""" + +from __future__ import annotations + +import json +from typing import Any, Iterator + +import pytest + +from together.error import ( + APIConnectionError, + APIError, + AuthenticationError, + JSONError, + RateLimitError, + ResponseError, + Timeout, + TogetherException, +) + + +class FakeCIMultiDictProxy: + """Mimics aiohttp's CIMultiDictProxy, which is not JSON-serializable.""" + + def __init__(self, data: dict[str, str]) -> None: + self._data = data + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def __getitem__(self, key: str) -> str: + return self._data[key] + + def __repr__(self) -> str: + return f"" + + +class TestExceptionReprNonSerializable: + """repr() must never crash, even with non-JSON-serializable attributes.""" + + def test_repr_with_non_serializable_headers(self) -> None: + """Core bug from issue #108: CIMultiDictProxy headers crash repr().""" + headers = FakeCIMultiDictProxy({"Content-Type": "application/json"}) + exc = TogetherException( + message="server error", + headers=headers, # type: ignore[arg-type] + http_status=500, + ) + # Before fix: TypeError: Object of type FakeCIMultiDictProxy is not + # JSON serializable + result = repr(exc) + assert "TogetherException" in result + assert "server error" in result + + def test_repr_with_dict_headers(self) -> None: + """Normal dict headers must still work (regression check).""" + exc = TogetherException( + message="bad request", + headers={"X-Request-Id": "abc-123"}, + http_status=400, + request_id="req-1", + ) + result = repr(exc) + parsed = json.loads(result.split("(", 1)[1].rsplit(")", 1)[0].strip("'\"")) + assert parsed["status"] == 400 + assert parsed["request_id"] == "req-1" + + def test_repr_with_none_headers(self) -> None: + """Default None headers (stored as {}) must work.""" + exc = TogetherException(message="oops") + result = repr(exc) + assert "TogetherException" in result + + def test_repr_with_string_headers(self) -> None: + """String headers must work.""" + exc = TogetherException(message="err", headers="raw-header") + result = repr(exc) + assert "raw-header" in result + + @pytest.mark.parametrize( + "exc_class", + [ + AuthenticationError, + ResponseError, + JSONError, + RateLimitError, + Timeout, + APIConnectionError, + APIError, + ], + ) + def test_subclasses_inherit_fix(self, exc_class: type) -> None: + """All subclasses inherit the safe repr via TogetherException.""" + headers = FakeCIMultiDictProxy({"X-Rate-Limit": "100"}) + exc = exc_class( + message="subclass test", + headers=headers, # type: ignore[arg-type] + http_status=429, + ) + result = repr(exc) + assert exc_class.__name__ in result