44import os
55import sys
66import tempfile
7+ import time
78import unittest
89from unittest import mock
910
1011import httpx
1112
1213from python_agent_harness .client import Client
1314from python_agent_harness .models import Message , ToolCall , ToolSpec , Usage
15+ from python_agent_harness import config
1416
1517# `discover -s tests` puts the tests dir on sys.path, but a direct
1618# `-m unittest tests.test_client` invocation does not — make the
@@ -1037,7 +1039,7 @@ def test_unexpected_error_resets_pool_but_is_not_retried(self):
10371039
10381040 def boom (* _a , ** _k ):
10391041 attempts ["n" ] += 1
1040- raise ApiError ("API error 401: unauthorized " )
1042+ raise ApiError ("API error 403: forbidden " )
10411043
10421044 with mock .patch .object (c , "_stream_response" , side_effect = boom ):
10431045 with self .assertRaises (ApiError ) as ctx :
@@ -1046,7 +1048,7 @@ def boom(*_a, **_k):
10461048 # exactly one attempt: permanent errors are NOT retried
10471049 self .assertEqual (attempts ["n" ], 1 )
10481050 # original error propagates unchanged (not re-wrapped as network)
1049- self .assertIn ("401 " , str (ctx .exception ))
1051+ self .assertIn ("403 " , str (ctx .exception ))
10501052 # pool still reset so the next request starts clean
10511053 self .assertIsNot (c ._http , old_http )
10521054 self .assertFalse (c ._http .is_closed )
@@ -1149,5 +1151,222 @@ def fake_stream(payload, on_delta, on_tool_call, usage):
11491151 c .close ()
11501152
11511153
1154+ class TestAuthRefreshOn401 (unittest .TestCase ):
1155+ """When a 401 is received, the client re-reads the API key from
1156+ config/env. If a new key is found, the request is retried once
1157+ with the updated credentials. If the key hasn't changed, the
1158+ error propagates immediately."""
1159+
1160+ def test_401_with_refreshed_key_retries_and_succeeds (self ):
1161+ """A 401 triggers key re-read; if the key changed, the request
1162+ is retried with the new key and can succeed."""
1163+ from python_agent_harness .client import AuthExpiredError
1164+
1165+ c = make_offline_client (retry_max = 3 , retry_base_delay = 0.01 , retry_max_delay = 0.01 )
1166+ calls = {"n" : 0 }
1167+
1168+ def fake_stream (payload , on_delta , on_tool_call , usage ):
1169+ n = calls ["n" ]
1170+ calls ["n" ] += 1
1171+ if n == 0 :
1172+ raise AuthExpiredError ("API error 401: Unauthorized" )
1173+ # second attempt succeeds with the refreshed key
1174+ return (["ok" ], [], {})
1175+
1176+ with mock .patch .object (c , "_stream_response" , side_effect = fake_stream ):
1177+ with mock .patch .object (c , "_refresh_api_key" , return_value = True ):
1178+ msg , _ = c .chat ([Message (role = "user" , content = "hi" )])
1179+
1180+ # retried once after refresh
1181+ self .assertEqual (calls ["n" ], 2 )
1182+ self .assertEqual (msg .text (), "ok" )
1183+ c .close ()
1184+
1185+ def test_401_with_unchanged_key_fails_immediately (self ):
1186+ """A 401 where the key hasn't changed on disk propagates as a
1187+ permanent ApiError (no retry)."""
1188+ from python_agent_harness .client import ApiError , AuthExpiredError
1189+
1190+ c = make_offline_client (retry_max = 3 , retry_base_delay = 0.01 , retry_max_delay = 0.01 )
1191+ calls = {"n" : 0 }
1192+
1193+ def fake_stream (payload , on_delta , on_tool_call , usage ):
1194+ calls ["n" ] += 1
1195+ raise AuthExpiredError ("API error 401: Unauthorized" )
1196+
1197+ with mock .patch .object (c , "_stream_response" , side_effect = fake_stream ):
1198+ with mock .patch .object (c , "_refresh_api_key" , return_value = False ):
1199+ with self .assertRaises (ApiError ) as ctx :
1200+ c .chat ([Message (role = "user" , content = "hi" )])
1201+
1202+ # exactly one attempt: key didn't change, no retry
1203+ self .assertEqual (calls ["n" ], 1 )
1204+ self .assertIn ("401" , str (ctx .exception ))
1205+ c .close ()
1206+
1207+ def test_401_only_retries_once_even_if_key_keeps_changing (self ):
1208+ """Even if _refresh_api_key returns True repeatedly (a bug or
1209+ race), the auth refresh retry is capped at one attempt to
1210+ prevent infinite loops."""
1211+ from python_agent_harness .client import ApiError , AuthExpiredError
1212+
1213+ c = make_offline_client (retry_max = 3 , retry_base_delay = 0.01 , retry_max_delay = 0.01 )
1214+ calls = {"n" : 0 }
1215+
1216+ def fake_stream (payload , on_delta , on_tool_call , usage ):
1217+ calls ["n" ] += 1
1218+ raise AuthExpiredError ("API error 401: Unauthorized" )
1219+
1220+ with mock .patch .object (c , "_stream_response" , side_effect = fake_stream ):
1221+ with mock .patch .object (c , "_refresh_api_key" , return_value = True ):
1222+ with self .assertRaises (ApiError ) as ctx :
1223+ c .chat ([Message (role = "user" , content = "hi" )])
1224+
1225+ # first attempt + one auth-refresh retry = 2 total
1226+ self .assertEqual (calls ["n" ], 2 )
1227+ self .assertIn ("401" , str (ctx .exception ))
1228+ c .close ()
1229+
1230+ def test_401_resets_http_pool (self ):
1231+ """A 401 must reset the connection pool (same as other errors)
1232+ so subsequent requests start clean."""
1233+ from python_agent_harness .client import ApiError , AuthExpiredError
1234+
1235+ c = make_offline_client (retry_max = 3 , retry_base_delay = 0.01 , retry_max_delay = 0.01 )
1236+ old_http = c ._http
1237+
1238+ def fake_stream (payload , on_delta , on_tool_call , usage ):
1239+ raise AuthExpiredError ("API error 401: Unauthorized" )
1240+
1241+ with mock .patch .object (c , "_stream_response" , side_effect = fake_stream ):
1242+ with mock .patch .object (c , "_refresh_api_key" , return_value = False ):
1243+ with self .assertRaises (ApiError ):
1244+ c .chat ([Message (role = "user" , content = "hi" )])
1245+
1246+ # pool was swapped out
1247+ self .assertIsNot (c ._http , old_http )
1248+ self .assertFalse (c ._http .is_closed )
1249+ c .close ()
1250+
1251+ def test_refresh_api_key_polls_until_key_changes (self ):
1252+ """_refresh_api_key polls the config file repeatedly until the
1253+ key changes, then returns True."""
1254+ import tempfile , json
1255+
1256+ cfg_path = tempfile .mktemp (suffix = ".json" )
1257+ # initially same key
1258+ with open (cfg_path , "w" ) as f :
1259+ json .dump ({"llm" : {"api_key" : "old-token" }}, f )
1260+
1261+ c = make_offline_client (config_path = cfg_path )
1262+ c .api_key = "old-token"
1263+
1264+ call_count = {"n" : 0 }
1265+ original_load = config .load_llm_config
1266+
1267+ def patched_load (path = None ):
1268+ call_count ["n" ] += 1
1269+ if call_count ["n" ] >= 3 :
1270+ # simulate external script updating the file
1271+ with open (cfg_path , "w" ) as f :
1272+ json .dump ({"llm" : {"api_key" : "refreshed-token" }}, f )
1273+ return original_load (path )
1274+
1275+ try :
1276+ with mock .patch .object (config , "load_llm_config" , side_effect = patched_load ):
1277+ result = c ._refresh_api_key (timeout = 5.0 , poll_interval = 0.05 )
1278+ self .assertTrue (result )
1279+ self .assertEqual (c .api_key , "refreshed-token" )
1280+ self .assertGreaterEqual (call_count ["n" ], 3 )
1281+ finally :
1282+ os .unlink (cfg_path )
1283+ c .close ()
1284+
1285+ def test_refresh_api_key_times_out_when_key_unchanged (self ):
1286+ """_refresh_api_key returns False after timeout if the key
1287+ never changes."""
1288+ import tempfile , json
1289+
1290+ cfg = {"llm" : {"api_key" : "same-token" }}
1291+ with tempfile .NamedTemporaryFile (mode = "w" , suffix = ".json" , delete = False ) as f :
1292+ json .dump (cfg , f )
1293+ cfg_path = f .name
1294+
1295+ try :
1296+ c = make_offline_client (config_path = cfg_path )
1297+ c .api_key = "same-token"
1298+ start = time .monotonic ()
1299+ result = c ._refresh_api_key (timeout = 0.2 , poll_interval = 0.05 )
1300+ elapsed = time .monotonic () - start
1301+ self .assertFalse (result )
1302+ # actually waited close to the timeout
1303+ self .assertGreaterEqual (elapsed , 0.15 )
1304+ finally :
1305+ os .unlink (cfg_path )
1306+ c .close ()
1307+
1308+ def test_refresh_api_key_aborts_on_cancel (self ):
1309+ """_refresh_api_key respects cancel_check and returns False
1310+ early without waiting the full timeout."""
1311+ import tempfile , json
1312+
1313+ cfg = {"llm" : {"api_key" : "same-token" }}
1314+ with tempfile .NamedTemporaryFile (mode = "w" , suffix = ".json" , delete = False ) as f :
1315+ json .dump (cfg , f )
1316+ cfg_path = f .name
1317+
1318+ try :
1319+ c = make_offline_client (config_path = cfg_path )
1320+ c .api_key = "same-token"
1321+ start = time .monotonic ()
1322+ result = c ._refresh_api_key (
1323+ cancel_check = lambda : True , # immediate cancel
1324+ timeout = 10.0 ,
1325+ poll_interval = 0.05 ,
1326+ )
1327+ elapsed = time .monotonic () - start
1328+ self .assertFalse (result )
1329+ # aborted quickly, not after 10s
1330+ self .assertLess (elapsed , 1.0 )
1331+ finally :
1332+ os .unlink (cfg_path )
1333+ c .close ()
1334+
1335+ def test_refresh_api_key_falls_back_to_env (self ):
1336+ """When no config file exists, _refresh_api_key reads from
1337+ environment variables."""
1338+ c = make_offline_client (config_path = "/nonexistent/config.json" )
1339+ c .api_key = "old-key"
1340+
1341+ with mock .patch .dict (os .environ , {"OPENAI_API_KEY" : "env-refreshed-key" }):
1342+ result = c ._refresh_api_key (timeout = 0.1 , poll_interval = 0.05 )
1343+ self .assertTrue (result )
1344+ self .assertEqual (c .api_key , "env-refreshed-key" )
1345+ c .close ()
1346+
1347+ def test_401_on_sync_request_also_triggers_refresh (self ):
1348+ """Non-streaming requests (chat_sync, used for titles) also
1349+ benefit from the auth refresh mechanism."""
1350+ from python_agent_harness .client import AuthExpiredError
1351+
1352+ c = make_offline_client (retry_max = 3 , retry_base_delay = 0.01 , retry_max_delay = 0.01 )
1353+ calls = {"n" : 0 }
1354+
1355+ def fake_sync (payload , on_delta , on_tool_call , usage ):
1356+ n = calls ["n" ]
1357+ calls ["n" ] += 1
1358+ if n == 0 :
1359+ raise AuthExpiredError ("API error 401: Unauthorized" )
1360+ return (["title generated" ], [], {})
1361+
1362+ with mock .patch .object (c , "_sync_response" , side_effect = fake_sync ):
1363+ with mock .patch .object (c , "_refresh_api_key" , return_value = True ):
1364+ msg , _ = c .chat_sync ([Message (role = "user" , content = "hi" )])
1365+
1366+ self .assertEqual (calls ["n" ], 2 )
1367+ self .assertEqual (msg .text (), "title generated" )
1368+ c .close ()
1369+
1370+
11521371if __name__ == "__main__" :
11531372 unittest .main ()
0 commit comments