Skip to content

Commit 0e26e33

Browse files
MaxwellCalkinclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 35fd835 commit 0e26e33

File tree

2 files changed

+111
-1
lines changed

2 files changed

+111
-1
lines changed

src/together/error.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ def __repr__(self) -> str:
4444
"status": self.http_status,
4545
"request_id": self.request_id,
4646
"headers": self.headers,
47-
}
47+
},
48+
default=str,
4849
)
4950
return "%s(%r)" % (self.__class__.__name__, repr_message)
5051

tests/unit/test_exception_repr.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Tests for TogetherException.__repr__ with non-JSON-serializable objects.
2+
3+
Regression tests for https://github.com/togethercomputer/together-python/issues/108
4+
where repr() on a TogetherException crashed with TypeError when headers
5+
contained non-serializable objects like aiohttp's CIMultiDictProxy.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from typing import Any, Iterator
12+
13+
import pytest
14+
15+
from together.error import (
16+
APIConnectionError,
17+
APIError,
18+
AuthenticationError,
19+
JSONError,
20+
RateLimitError,
21+
ResponseError,
22+
Timeout,
23+
TogetherException,
24+
)
25+
26+
27+
class FakeCIMultiDictProxy:
28+
"""Mimics aiohttp's CIMultiDictProxy, which is not JSON-serializable."""
29+
30+
def __init__(self, data: dict[str, str]) -> None:
31+
self._data = data
32+
33+
def __iter__(self) -> Iterator[str]:
34+
return iter(self._data)
35+
36+
def __len__(self) -> int:
37+
return len(self._data)
38+
39+
def __getitem__(self, key: str) -> str:
40+
return self._data[key]
41+
42+
def __repr__(self) -> str:
43+
return f"<CIMultiDictProxy({self._data!r})>"
44+
45+
46+
class TestExceptionReprNonSerializable:
47+
"""repr() must never crash, even with non-JSON-serializable attributes."""
48+
49+
def test_repr_with_non_serializable_headers(self) -> None:
50+
"""Core bug from issue #108: CIMultiDictProxy headers crash repr()."""
51+
headers = FakeCIMultiDictProxy({"Content-Type": "application/json"})
52+
exc = TogetherException(
53+
message="server error",
54+
headers=headers, # type: ignore[arg-type]
55+
http_status=500,
56+
)
57+
# Before fix: TypeError: Object of type FakeCIMultiDictProxy is not
58+
# JSON serializable
59+
result = repr(exc)
60+
assert "TogetherException" in result
61+
assert "server error" in result
62+
63+
def test_repr_with_dict_headers(self) -> None:
64+
"""Normal dict headers must still work (regression check)."""
65+
exc = TogetherException(
66+
message="bad request",
67+
headers={"X-Request-Id": "abc-123"},
68+
http_status=400,
69+
request_id="req-1",
70+
)
71+
result = repr(exc)
72+
parsed = json.loads(result.split("(", 1)[1].rsplit(")", 1)[0].strip("'\""))
73+
assert parsed["status"] == 400
74+
assert parsed["request_id"] == "req-1"
75+
76+
def test_repr_with_none_headers(self) -> None:
77+
"""Default None headers (stored as {}) must work."""
78+
exc = TogetherException(message="oops")
79+
result = repr(exc)
80+
assert "TogetherException" in result
81+
82+
def test_repr_with_string_headers(self) -> None:
83+
"""String headers must work."""
84+
exc = TogetherException(message="err", headers="raw-header")
85+
result = repr(exc)
86+
assert "raw-header" in result
87+
88+
@pytest.mark.parametrize(
89+
"exc_class",
90+
[
91+
AuthenticationError,
92+
ResponseError,
93+
JSONError,
94+
RateLimitError,
95+
Timeout,
96+
APIConnectionError,
97+
APIError,
98+
],
99+
)
100+
def test_subclasses_inherit_fix(self, exc_class: type) -> None:
101+
"""All subclasses inherit the safe repr via TogetherException."""
102+
headers = FakeCIMultiDictProxy({"X-Rate-Limit": "100"})
103+
exc = exc_class(
104+
message="subclass test",
105+
headers=headers, # type: ignore[arg-type]
106+
http_status=429,
107+
)
108+
result = repr(exc)
109+
assert exc_class.__name__ in result

0 commit comments

Comments
 (0)