From 3e1816020c6cb8e1657d42bb3d380879dd9d26ab Mon Sep 17 00:00:00 2001 From: Giulio Leone <6887247+giulio-leone@users.noreply.github.com> Date: Sun, 8 Mar 2026 03:39:01 +0100 Subject: [PATCH 1/2] fix: prevent TogetherException repr crash on non-JSON-serializable headers When headers contain non-JSON-serializable objects (e.g. aiohttp's CIMultiDictProxy), `json.dumps()` in `__repr__` raises TypeError, making it impossible to print or log the exception. Add `default=str` to json.dumps so non-serializable objects fall back to their string representation instead of crashing. Fixes #108 Signed-off-by: Giulio Leone <6887247+giulio-leone@users.noreply.github.com> --- src/together/error.py | 3 +- tests/unit/test_error_repr.py | 122 ++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_error_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_error_repr.py b/tests/unit/test_error_repr.py new file mode 100644 index 00000000..11c93cb1 --- /dev/null +++ b/tests/unit/test_error_repr.py @@ -0,0 +1,122 @@ +"""Tests for TogetherException.__repr__ with non-JSON-serializable headers.""" + +from __future__ import annotations + +import json +from collections import OrderedDict +from typing import Any, Iterator +from unittest.mock import MagicMock + +import pytest + +from together.error import ( + TogetherException, + AuthenticationError, + ResponseError, + APIError, +) + + +class FakeMultiDictProxy: + """Simulates aiohttp's CIMultiDictProxy — 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: + """Verify __repr__ doesn't crash on non-JSON-serializable headers (issue #108).""" + + def test_repr_with_dict_headers(self) -> None: + """Normal dict headers should still work fine.""" + exc = TogetherException( + message="test error", + headers={"Content-Type": "application/json"}, + http_status=400, + request_id="req-123", + ) + result = repr(exc) + assert "TogetherException" in result + assert "test error" in result + + def test_repr_with_multidict_proxy_headers(self) -> None: + """CIMultiDictProxy-like headers must not crash repr (issue #108).""" + fake_headers = FakeMultiDictProxy( + {"Content-Type": "application/json", "X-Request-Id": "abc"} + ) + exc = TogetherException( + message="server error", + headers=fake_headers, # type: ignore[arg-type] + http_status=500, + request_id="req-456", + ) + # Before fix: TypeError: Object of type FakeMultiDictProxy + # is not JSON serializable + result = repr(exc) + assert "TogetherException" in result + assert "server error" in result + + def test_repr_with_none_headers(self) -> None: + """None headers (default) should work.""" + exc = TogetherException(message="no headers") + result = repr(exc) + assert "TogetherException" in result + + def test_repr_with_string_headers(self) -> None: + """String headers should work.""" + exc = TogetherException( + message="string headers", headers="raw-header-string" + ) + result = repr(exc) + assert "TogetherException" in result + + def test_repr_with_nested_non_serializable(self) -> None: + """Dict headers containing non-serializable values should not crash.""" + exc = TogetherException( + message="nested issue", + headers={"key": MagicMock()}, # type: ignore[dict-item] + http_status=502, + ) + result = repr(exc) + assert "TogetherException" in result + + def test_repr_output_is_valid_after_fix(self) -> None: + """repr should produce parseable output (class name + JSON string).""" + exc = TogetherException( + message="validation error", + headers={"Authorization": "Bearer ***"}, + http_status=422, + request_id="req-789", + ) + result = repr(exc) + assert result.startswith("TogetherException(") + # The inner string should be valid JSON + inner = result[len("TogetherException(") + 1 : -2] # strip quotes + parsed = json.loads(inner) + assert parsed["status"] == 422 + assert parsed["request_id"] == "req-789" + + def test_subclass_repr_with_non_serializable_headers(self) -> None: + """Subclasses should also benefit from the fix.""" + fake_headers = FakeMultiDictProxy({"X-Rate-Limit": "100"}) + + for ExcClass in (AuthenticationError, ResponseError, APIError): + exc = ExcClass( + message="subclass test", + headers=fake_headers, # type: ignore[arg-type] + http_status=429, + ) + result = repr(exc) + assert ExcClass.__name__ in result From df6e869b73db3f5798b60d2c86b81366932b9d84 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Mon, 23 Mar 2026 22:51:39 +0100 Subject: [PATCH 2/2] fix: strengthen TogetherException repr handling - preserve mapping-like headers as structured JSON in __repr__ - validate the regression with a real CIMultiDictProxy repro Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/together/error.py | 13 +++++++++- tests/unit/test_error_repr.py | 48 +++++++++++++---------------------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/together/error.py b/src/together/error.py index 4e80cbd9..97e33c85 100644 --- a/src/together/error.py +++ b/src/together/error.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from collections.abc import Mapping from typing import Any, Dict from requests import RequestException @@ -8,6 +9,16 @@ from together.types.error import TogetherErrorResponse +def _json_safe_headers(headers: str | Dict[Any, Any] | None) -> str | Dict[Any, Any]: + if headers is None: + return {} + if isinstance(headers, str): + return headers + if isinstance(headers, Mapping): + return {str(key): value for key, value in headers.items()} + return str(headers) + + class TogetherException(Exception): def __init__( self, @@ -43,7 +54,7 @@ def __repr__(self) -> str: "response": self._message, "status": self.http_status, "request_id": self.request_id, - "headers": self.headers, + "headers": _json_safe_headers(self.headers), }, default=str, ) diff --git a/tests/unit/test_error_repr.py b/tests/unit/test_error_repr.py index 11c93cb1..e94dd635 100644 --- a/tests/unit/test_error_repr.py +++ b/tests/unit/test_error_repr.py @@ -3,39 +3,18 @@ from __future__ import annotations import json -from collections import OrderedDict -from typing import Any, Iterator from unittest.mock import MagicMock -import pytest +from multidict import CIMultiDict, CIMultiDictProxy from together.error import ( - TogetherException, + APIError, AuthenticationError, ResponseError, - APIError, + TogetherException, ) -class FakeMultiDictProxy: - """Simulates aiohttp's CIMultiDictProxy — 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: """Verify __repr__ doesn't crash on non-JSON-serializable headers (issue #108).""" @@ -52,21 +31,28 @@ def test_repr_with_dict_headers(self) -> None: assert "test error" in result def test_repr_with_multidict_proxy_headers(self) -> None: - """CIMultiDictProxy-like headers must not crash repr (issue #108).""" - fake_headers = FakeMultiDictProxy( - {"Content-Type": "application/json", "X-Request-Id": "abc"} + """Real CIMultiDictProxy headers must not crash repr (issue #108).""" + headers = CIMultiDictProxy( + CIMultiDict( + {"Content-Type": "application/json", "X-Request-Id": "abc"} + ) ) exc = TogetherException( message="server error", - headers=fake_headers, # type: ignore[arg-type] + headers=headers, # type: ignore[arg-type] http_status=500, request_id="req-456", ) - # Before fix: TypeError: Object of type FakeMultiDictProxy + # Before fix: TypeError: Object of type CIMultiDictProxy # is not JSON serializable result = repr(exc) assert "TogetherException" in result assert "server error" in result + parsed = json.loads(result[len("TogetherException(") + 1 : -2]) + assert parsed["headers"] == { + "Content-Type": "application/json", + "X-Request-Id": "abc", + } def test_repr_with_none_headers(self) -> None: """None headers (default) should work.""" @@ -110,12 +96,12 @@ def test_repr_output_is_valid_after_fix(self) -> None: def test_subclass_repr_with_non_serializable_headers(self) -> None: """Subclasses should also benefit from the fix.""" - fake_headers = FakeMultiDictProxy({"X-Rate-Limit": "100"}) + headers = CIMultiDictProxy(CIMultiDict({"X-Rate-Limit": "100"})) for ExcClass in (AuthenticationError, ResponseError, APIError): exc = ExcClass( message="subclass test", - headers=fake_headers, # type: ignore[arg-type] + headers=headers, # type: ignore[arg-type] http_status=429, ) result = repr(exc)