Skip to content

Commit c20ff25

Browse files
authored
Update test_client.py
1 parent e83658b commit c20ff25

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

tests/test_client.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,156 @@ def test_cancel_during_backoff_also_resets_pool(self):
998998
self.assertFalse(c._http.is_closed)
999999
c.close()
10001000

1001+
def test_bare_oserror_is_retried_and_resets_pool(self):
1002+
"""A raw OSError (e.g. ConnectionResetError / ssl.SSLError) that
1003+
leaks past httpx must be treated like a connection error: it is
1004+
retried on a fresh client and surfaces as ApiError. Without
1005+
this, the poisoned connection would stay in the pool and doom
1006+
every following request in the session."""
1007+
from python_agent_harness.client import ApiError
1008+
1009+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
1010+
old_http = c._http
1011+
attempts = {"n": 0}
1012+
1013+
def boom(*_a, **_k):
1014+
attempts["n"] += 1
1015+
raise ConnectionResetError(104, "Connection reset by peer")
1016+
1017+
with mock.patch.object(c, "_stream_response", side_effect=boom):
1018+
with self.assertRaises(ApiError):
1019+
c.chat([Message(role="user", content="hi")])
1020+
1021+
# retried up to the budget (not a single-shot failure)
1022+
self.assertEqual(attempts["n"], 3)
1023+
# pool reset: the poisoned client was swapped out for a fresh one
1024+
self.assertIsNot(c._http, old_http)
1025+
self.assertFalse(c._http.is_closed)
1026+
c.close()
1027+
1028+
def test_unexpected_error_resets_pool_but_is_not_retried(self):
1029+
"""A non-connection error (e.g. a bug, or a permanent ApiError)
1030+
must fail fast — a single attempt, no retry — yet still reset
1031+
the pool so the next request never reuses a poisoned connection."""
1032+
from python_agent_harness.client import ApiError
1033+
1034+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
1035+
old_http = c._http
1036+
attempts = {"n": 0}
1037+
1038+
def boom(*_a, **_k):
1039+
attempts["n"] += 1
1040+
raise ApiError("API error 401: unauthorized")
1041+
1042+
with mock.patch.object(c, "_stream_response", side_effect=boom):
1043+
with self.assertRaises(ApiError) as ctx:
1044+
c.chat([Message(role="user", content="hi")])
1045+
1046+
# exactly one attempt: permanent errors are NOT retried
1047+
self.assertEqual(attempts["n"], 1)
1048+
# original error propagates unchanged (not re-wrapped as network)
1049+
self.assertIn("401", str(ctx.exception))
1050+
# pool still reset so the next request starts clean
1051+
self.assertIsNot(c._http, old_http)
1052+
self.assertFalse(c._http.is_closed)
1053+
c.close()
1054+
1055+
1056+
class TestClientCallbackIsolation(unittest.TestCase):
1057+
"""A presentational callback (on_delta / on_tool_call) failure must
1058+
never be mistaken for a transport error and trigger a retry — even
1059+
when it raises an OSError such as BrokenPipeError on a closed
1060+
terminal."""
1061+
1062+
def test_on_delta_oserror_does_not_trigger_retry(self):
1063+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
1064+
calls = {"n": 0}
1065+
1066+
def fake_stream(payload, on_delta, on_tool_call, usage):
1067+
on_delta("hello") # invokes the wrapped user callback
1068+
return (["hello"], [], {})
1069+
1070+
def bad_on_delta(_chunk):
1071+
calls["n"] += 1
1072+
raise BrokenPipeError(32, "Broken pipe")
1073+
1074+
with mock.patch.object(c, "_stream_response", side_effect=fake_stream):
1075+
msg, _ = c.chat(
1076+
[Message(role="user", content="hi")],
1077+
on_delta=bad_on_delta,
1078+
stream=True,
1079+
)
1080+
# request succeeded despite the UI callback blowing up, and it
1081+
# ran exactly once (no spurious network retry)
1082+
self.assertEqual(msg.text(), "hello")
1083+
self.assertEqual(calls["n"], 1)
1084+
c.close()
1085+
1086+
def test_on_tool_call_exception_does_not_trigger_retry(self):
1087+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
1088+
calls = {"n": 0}
1089+
1090+
def fake_stream(payload, on_delta, on_tool_call, usage):
1091+
on_tool_call("Read", "call_1", '{"path":"x"}')
1092+
return ([], [], {0: {"id": "call_1", "name": "Read", "arguments": '{"path":"x"}'}})
1093+
1094+
def bad_on_tool_call(_n, _i, _f):
1095+
calls["n"] += 1
1096+
raise RuntimeError("render boom")
1097+
1098+
with mock.patch.object(c, "_stream_response", side_effect=fake_stream):
1099+
msg, _ = c.chat(
1100+
[Message(role="user", content="hi")],
1101+
on_tool_call=bad_on_tool_call,
1102+
stream=True,
1103+
)
1104+
self.assertTrue(msg.tool_calls)
1105+
self.assertEqual(calls["n"], 1)
1106+
c.close()
1107+
1108+
1109+
class TestRetryAfterPartialStream(unittest.TestCase):
1110+
"""A transient status (429/5xx) arriving AFTER a partial stream was
1111+
dropped (and cleared) must still be retried — emission is tracked
1112+
per-attempt, not for the whole request."""
1113+
1114+
def test_transient_status_after_dropped_partial_is_retried(self):
1115+
from python_agent_harness.client import RetryableApiError
1116+
1117+
c = make_offline_client(retry_max=3, retry_base_delay=0.01, retry_max_delay=0.01)
1118+
calls = {"n": 0}
1119+
deltas: list[str] = []
1120+
retries = {"n": 0}
1121+
1122+
def fake_stream(payload, on_delta, on_tool_call, usage):
1123+
n = calls["n"]
1124+
calls["n"] += 1
1125+
if n == 0:
1126+
on_delta("part") # partial delivered to the caller
1127+
raise httpx.ReadError("stream dropped mid-body")
1128+
if n == 1:
1129+
# transient status on the retry, AFTER a partial was
1130+
# already streamed on attempt 0
1131+
raise RetryableApiError("API error 429", None)
1132+
return (["full answer"], [], {})
1133+
1134+
with mock.patch.object(c, "_stream_response", side_effect=fake_stream):
1135+
msg, _ = c.chat(
1136+
[Message(role="user", content="hi")],
1137+
on_delta=deltas.append,
1138+
on_retry=lambda: retries.__setitem__("n", retries["n"] + 1),
1139+
stream=True,
1140+
)
1141+
1142+
# all three attempts ran: the 429 after the dropped partial was
1143+
# NOT treated as terminal (the pre-fix bug gave up here)
1144+
self.assertEqual(calls["n"], 3)
1145+
# final message is the last attempt's content, no duplication
1146+
self.assertEqual(msg.text(), "full answer")
1147+
# the dropped partial was cleared via on_retry at least once
1148+
self.assertGreaterEqual(retries["n"], 1)
1149+
c.close()
1150+
10011151

10021152
if __name__ == "__main__":
10031153
unittest.main()

0 commit comments

Comments
 (0)