Skip to content

Commit 577daec

Browse files
authored
Update client.py
1 parent dd12c3c commit 577daec

1 file changed

Lines changed: 72 additions & 0 deletions

File tree

python_agent_harness/client.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ def __init__(self, message: str, retry_after: str | None = None) -> None:
3636
self.retry_after = retry_after
3737

3838

39+
class AuthExpiredError(ApiError):
40+
"""Raised on HTTP 401 — the credential may have expired.
41+
42+
Handled specially in the retry loop: the API key is re-read from
43+
config/environment (an external process may have refreshed the
44+
token) and the request is retried once with the new key. If the
45+
key hasn't changed, the error is permanent and propagated as a
46+
plain ApiError.
47+
"""
48+
49+
3950
def _retryable_status(status: int) -> bool:
4051
"""429 and 5xx are transient; every other error is permanent."""
4152
return status == 429 or status >= 500
@@ -157,6 +168,7 @@ def __init__(
157168
retry_max: int | None = None,
158169
retry_base_delay: float | None = None,
159170
retry_max_delay: float | None = None,
171+
config_path: str | None = None,
160172
) -> None:
161173
self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
162174
self.api_key = api_key or _default_api_key()
@@ -170,6 +182,7 @@ def __init__(
170182
self.retry_max_delay = (
171183
config.API_RETRY_MAX_DELAY if retry_max_delay is None else retry_max_delay
172184
)
185+
self._config_path = config_path
173186
self._http = httpx.Client(timeout=timeout, verify=self.verify)
174187
# True while the in-flight request was aborted (Ctrl-C): a
175188
# connection error on an aborted request must NOT be retried —
@@ -219,6 +232,43 @@ def _reset_http(self) -> None:
219232
except Exception: # noqa: BLE001 - best effort
220233
pass
221234

235+
def _refresh_api_key(
236+
self,
237+
cancel_check: Callable[[], bool] | None = None,
238+
timeout: float = 30.0,
239+
poll_interval: float = 2.0,
240+
) -> bool:
241+
"""Re-read the API key from the config file and environment.
242+
243+
Called on HTTP 401 (auth expired): an external process (e.g. a
244+
token refresh script) may need time to write a new key. This
245+
method polls the config file / environment every
246+
``poll_interval`` seconds for up to ``timeout`` seconds, waiting
247+
for the key to change. If a fresh key is found that differs
248+
from the current one, update ``self.api_key`` and return True
249+
(the caller should retry). If the key remains unchanged after
250+
the timeout, return False (permanent auth failure).
251+
252+
``cancel_check`` is polled each iteration so Ctrl-C aborts the
253+
wait promptly.
254+
"""
255+
deadline = time.monotonic() + timeout
256+
while True:
257+
try:
258+
settings = config.load_llm_config(self._config_path)
259+
new_key = settings.get("api_key") or _default_api_key()
260+
except Exception: # noqa: BLE001 - config read must not crash
261+
new_key = _default_api_key()
262+
if new_key and new_key != self.api_key:
263+
self.api_key = new_key
264+
return True
265+
remaining = deadline - time.monotonic()
266+
if remaining <= 0:
267+
return False
268+
if cancel_check is not None and cancel_check():
269+
return False
270+
time.sleep(min(poll_interval, remaining))
271+
222272
# -- request plumbing -------------------------------------------------
223273
def _headers(self, stream: bool = True) -> dict[str, str]:
224274
h = {
@@ -333,6 +383,7 @@ def wrap_tool_call(name: str, call_id: str, fragment: str) -> None:
333383
pass
334384

335385
attempt = 0
386+
auth_refreshed = False
336387
while True:
337388
attempt += 1
338389
# Track emission per-attempt. Whether a PRIOR attempt
@@ -365,6 +416,23 @@ def wrap_tool_call(name: str, call_id: str, fragment: str) -> None:
365416
on_retry()
366417
if self._sleep_backoff(attempt, e.retry_after, cancel_check):
367418
raise
419+
except AuthExpiredError as e:
420+
# HTTP 401: the credential (often a JWT with a short
421+
# TTL) may have expired. Re-read the API key from the
422+
# config file / environment — an external token-refresh
423+
# process may have written a new one. Poll for up to
424+
# 30s waiting for the key to change; retry once if it
425+
# does. Fail immediately if already refreshed once
426+
# (prevents infinite loops).
427+
self._reset_http()
428+
if auth_refreshed or not self._refresh_api_key(cancel_check):
429+
raise ApiError(str(e)) from e
430+
auth_refreshed = True
431+
# Key refreshed — retry immediately (no backoff needed,
432+
# and only one extra attempt regardless of retry_max)
433+
if emitted and on_retry is not None:
434+
on_retry()
435+
continue
368436
except (httpx.HTTPError, OSError) as e:
369437
# connection-level failures: connect errors, timeouts,
370438
# dropped streams. ``httpx.HTTPError`` covers everything
@@ -476,6 +544,8 @@ def _stream_response(
476544
if resp.status_code >= 400:
477545
body = resp.read().decode("utf-8", "replace")
478546
message = f"API error {resp.status_code}: {body[:500]}"
547+
if resp.status_code == 401:
548+
raise AuthExpiredError(message)
479549
if _retryable_status(resp.status_code):
480550
raise RetryableApiError(
481551
message, resp.headers.get("Retry-After")
@@ -532,6 +602,8 @@ def _sync_response(
532602
)
533603
if resp.status_code >= 400:
534604
message = f"API error {resp.status_code}: {resp.text[:500]}"
605+
if resp.status_code == 401:
606+
raise AuthExpiredError(message)
535607
if _retryable_status(resp.status_code):
536608
raise RetryableApiError(
537609
message, resp.headers.get("Retry-After")

0 commit comments

Comments
 (0)