@@ -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+
3950def _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