Skip to content

Commit eb5da9e

Browse files
authored
Update test_client.py
1 parent 577daec commit eb5da9e

1 file changed

Lines changed: 221 additions & 2 deletions

File tree

tests/test_client.py

Lines changed: 221 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
import os
55
import sys
66
import tempfile
7+
import time
78
import unittest
89
from unittest import mock
910

1011
import httpx
1112

1213
from python_agent_harness.client import Client
1314
from 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+
11521371
if __name__ == "__main__":
11531372
unittest.main()

0 commit comments

Comments
 (0)