@@ -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
10021152if __name__ == "__main__" :
10031153 unittest .main ()
0 commit comments