@@ -248,6 +248,36 @@ def _ping() -> Dict[str, Any]:
248248 )
249249
250250
251+ def _refresh_oidc_from_cache (
252+ issuer : str ,
253+ client_id : str ,
254+ refresh_token : str ,
255+ scope : Optional [str ],
256+ audience : Optional [str ] = None ,
257+ ) -> Optional [Dict [str , Any ]]:
258+ """Exchange a cached refresh token for new tokens, persisting the result.
259+
260+ Shared by the ``init()`` path (``_obtain_oidc_tokens``) and the request path
261+ (``_ensure_fresh_oidc_token``) so their refresh behaviour can't drift. On
262+ success the refreshed entry is cached and returned. On a failed refresh (e.g.
263+ revoked token / ``invalid_grant``) the stale entry is deleted and None is
264+ returned, so a dead refresh token isn't re-attempted; the caller decides how
265+ to recover (``init()`` falls back to device flow, the request path stops).
266+ """
267+ from .credentials_store import delete_cached_entry , upsert_cached_entry
268+ from .oidc_device import try_refresh_cached_tokens
269+
270+ try :
271+ new_tokens = try_refresh_cached_tokens (
272+ issuer , client_id , refresh_token , scope , audience = audience
273+ )
274+ except ValidMindAuthError :
275+ delete_cached_entry (issuer , client_id , audience = audience )
276+ return None
277+ upsert_cached_entry (issuer , client_id , new_tokens , audience = audience )
278+ return new_tokens
279+
280+
251281def _obtain_oidc_tokens (
252282 issuer : str ,
253283 client_id : str ,
@@ -256,35 +286,25 @@ def _obtain_oidc_tokens(
256286) -> Dict [str , Any ]:
257287 """Return a credentials entry dict with access_token, expires_at, refresh_token, etc."""
258288 from .credentials_store import (
259- delete_cached_entry ,
260289 get_cached_entry ,
261290 is_expired ,
262291 normalize_client_id ,
263292 normalize_issuer ,
264293 upsert_cached_entry ,
265294 )
266- from .oidc_device import run_device_flow , try_refresh_cached_tokens
295+ from .oidc_device import run_device_flow
267296
268297 norm_issuer = normalize_issuer (issuer )
269298 norm_client_id = normalize_client_id (client_id )
270299 cached = get_cached_entry (norm_issuer , norm_client_id , audience = audience )
271300 if cached and not is_expired (cached ):
272301 return cached
273302 if cached and cached .get ("refresh_token" ):
274- try :
275- new_tokens = try_refresh_cached_tokens (
276- norm_issuer ,
277- norm_client_id ,
278- cached ["refresh_token" ],
279- scope ,
280- audience = audience ,
281- )
282- upsert_cached_entry (
283- norm_issuer , norm_client_id , new_tokens , audience = audience
284- )
303+ new_tokens = _refresh_oidc_from_cache (
304+ norm_issuer , norm_client_id , cached ["refresh_token" ], scope , audience
305+ )
306+ if new_tokens is not None :
285307 return new_tokens
286- except ValidMindAuthError :
287- delete_cached_entry (norm_issuer , norm_client_id , audience = audience )
288308 tokens = run_device_flow (norm_issuer , norm_client_id , scope , audience = audience )
289309 upsert_cached_entry (norm_issuer , norm_client_id , tokens , audience = audience )
290310 return tokens
@@ -312,6 +332,19 @@ def _set_oidc_access_token(entry: Dict[str, Any]) -> None:
312332 _invalidate_async_session ()
313333
314334
335+ def _clear_oidc_access_token () -> None :
336+ """Drop the in-memory OIDC token and pooled session after an unrecoverable refresh.
337+
338+ Pairs with deleting the cached entry: with no usable token in memory, the next
339+ request fails fast at header build with the clear "run vm.init()" error instead
340+ of sending a doomed request just to get a 401 back.
341+ """
342+ global _access_token , _oidc_expires_at
343+ _access_token = None
344+ _oidc_expires_at = None
345+ _invalidate_async_session ()
346+
347+
315348def _oidc_token_is_stale () -> bool :
316349 """Cheap in-memory expiry check (no file I/O) using the 120s skew in is_expired."""
317350 from .credentials_store import is_expired
@@ -327,16 +360,16 @@ def _ensure_fresh_oidc_token(force: bool = False) -> bool:
327360 starts failing on every call until ``init()`` is re-run. This uses the cached
328361 refresh token (requires ``offline_access``, the default scope) to obtain a new
329362 access token in place. Returns True when a usable token is in place, False when
330- no refresh was possible (non-OIDC mode, or no cached refresh token).
363+ no refresh was possible (non-OIDC mode, no cached refresh token, or a failed
364+ refresh — in which case the cached entry is dropped, matching init()).
331365 """
332366 if _auth_mode != "oidc" or _oidc_login_context is None :
333367 return False
334368 # Hot path: skip file I/O and the lock while the current token is still valid.
335369 if not force and not _oidc_token_is_stale ():
336370 return True
337371
338- from .credentials_store import get_cached_entry , is_expired , upsert_cached_entry
339- from .oidc_device import try_refresh_cached_tokens
372+ from .credentials_store import get_cached_entry , is_expired
340373
341374 ctx = _oidc_login_context
342375 issuer = ctx ["issuer" ]
@@ -355,13 +388,18 @@ def _ensure_fresh_oidc_token(force: bool = False) -> bool:
355388 refresh_token = entry .get ("refresh_token" )
356389 if not refresh_token :
357390 return False
358- try :
359- new_tokens = try_refresh_cached_tokens (
360- issuer , client_id , refresh_token , scope , audience = audience
361- )
362- except ValidMindAuthError :
391+ # Shared with init(): on failure the cached entry is dropped so a
392+ # bad/revoked refresh token isn't re-attempted on every request. Recovery
393+ # is re-running vm.init() (surfaced by the clear 401 auth error); unlike
394+ # init() the request path can't fall back to interactive device flow.
395+ new_tokens = _refresh_oidc_from_cache (
396+ issuer , client_id , refresh_token , scope , audience
397+ )
398+ if new_tokens is None :
399+ # Hard failure: the cache entry was dropped; clear the in-memory token
400+ # too so the next request fails fast with the clear re-auth message.
401+ _clear_oidc_access_token ()
363402 return False
364- upsert_cached_entry (issuer , client_id , new_tokens , audience = audience )
365403 _set_oidc_access_token (new_tokens )
366404 return True
367405
@@ -757,14 +795,15 @@ def _validate_log_text_context(
757795
758796def generate_qualitative_text (text_generation_data : Dict [str , Any ]) -> Dict [str , Any ]:
759797 """Generate qualitative text using the ValidMind AI API."""
798+ _ensure_fresh_oidc_token ()
760799 r = requests .post (
761800 url = _get_url ("ai/generate/qualitative_text_generation" ),
762801 headers = _get_api_headers (),
763802 json = text_generation_data ,
764803 )
765804
766805 if r .status_code != 200 :
767- raise_api_error ( r .text )
806+ _raise_for_api_error ( r . status_code , r .text )
768807
769808 return r .json ()
770809
@@ -1002,13 +1041,14 @@ def log_metric(
10021041
10031042
10041043def generate_test_result_description (test_result_data : Dict [str , Any ]) -> str :
1044+ _ensure_fresh_oidc_token ()
10051045 r = requests .post (
10061046 url = _get_url ("ai/generate/test_result_description" ),
10071047 headers = _get_api_headers (),
10081048 json = test_result_data ,
10091049 )
10101050
10111051 if r .status_code != 200 :
1012- raise_api_error ( r .text )
1052+ _raise_for_api_error ( r . status_code , r .text )
10131053
10141054 return r .json ()
0 commit comments