From b9c7094bd79439ce791548c511f8fbdef1f6dff1 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] 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