Files
hermes-agent/hermes_cli/dashboard_auth/refresh_singleflight.py
teknium1 5dea46d13d fix(dashboard-auth): one refresh single-flight for the cookie gate and the native route, off the event loop
The cookie gate (middleware._attempt_refresh) never coalesced concurrent requests
carrying the same stale refresh token, so a browser/desktop burst after access-token
expiry replayed a just-rotated RT into the provider's reuse detection and the whole
session was revoked (#55712). Both refresh paths also ran the synchronous provider
HTTP call on the ASGI event loop, which wedged /api/status behind a slow IdP.

Generalise Doud-FR's native-route single-flight (#71548) into
refresh_singleflight.refresh_session_coalesced and use it from both paths, each in
run_in_threadpool. The replay key is the RT alone: a burst that straddles a network
change must still coalesce, and whoever presents the RT already owns the session.
Middleware keeps its refresh_expired / provider_unreachable audit events via callbacks.

Live E2E (evals/dashboard_auth/refresh_singleflight_live_e2e.py, real uvicorn, stub
rotating IdP with reuse detection): base 1/4 requests survive each burst, 4 provider
calls, /api/status 2.7 s behind one refresh; fixed 4/4, 1 call, 10 ms.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
2026-09-13 09:40:44 -07:00

108 lines
4.5 KiB
Python

"""Single-flight + short replay cache for rotating refresh tokens (both refresh paths).
Rotating refresh tokens with reuse detection (Nous Portal, Authelia, most OIDC IdPs) make a
replay of an already-rotated RT fatal: the provider revokes the whole session. The desktop and
the browser both fire bursts of parallel requests on wake or after the access token lapses,
each still carrying the same old RT, so the gateway must let exactly ONE of them reach the
provider and hand the rotated session to the rest. The cookie gate (``middleware``) and the
native bearer route (``routes.auth_native_refresh``) share this one flight table.
A provider hint only orders discovery: it must neither split one rotating credential's lock
nor let an unrelated provider reuse its result. Raw refresh tokens are never keys.
"""
from __future__ import annotations
import hashlib
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Optional
from hermes_cli.dashboard_auth.base import DashboardAuthProvider, RefreshExpiredError, Session
from hermes_cli.dashboard_auth.request_utils import scan_session_providers
# The success TTL covers the window between the winning response and the siblings' arrival
# (a laptop waking from sleep can deliver its burst over many seconds); the failure TTL only
# absorbs a retry storm against a token the provider has already declared dead.
_SUCCESS_TTL = 30.0
_FAILURE_TTL = 5.0
_MAX_ENTRIES = 256
_guard = threading.Lock()
@dataclass
class _Flight:
lock: threading.Lock = field(default_factory=threading.Lock)
users: int = 0
# Hold the provider itself while caching: replacement (including same-name scoped
# registrations) invalidates identity, and Python cannot recycle its id under a live entry.
_cache: dict[tuple[int, bytes], tuple[float, DashboardAuthProvider, Session | None]] = {}
_flights: dict[tuple[int, bytes], _Flight] = {}
def _prune(now: float) -> None:
for key, (expires, _, _) in list(_cache.items()):
if expires <= now:
del _cache[key]
while len(_cache) > _MAX_ENTRIES:
del _cache[min(_cache, key=lambda key: _cache[key][0])]
def _refresh_provider(provider: DashboardAuthProvider, token: str) -> Session | None:
# Keyed on the token alone: whoever presents this RT already owns the session, and a client
# that changed network between two requests of one burst must still hit the cache.
digest = hashlib.sha256(token.encode()).digest()
key = (id(provider), digest)
with _guard:
_prune(time.monotonic())
flight = _flights.setdefault(key, _Flight())
flight.users += 1
try:
with flight.lock:
with _guard:
cached = _cache.get(key)
if cached is not None and cached[0] > time.monotonic():
return cached[2]
try:
session = provider.refresh_session(refresh_token=token)
except RefreshExpiredError:
session = None
# ProviderError and unexpected execution failures are deliberately not cached.
with _guard:
now = time.monotonic()
_cache[key] = (now + (_SUCCESS_TTL if session is not None else _FAILURE_TTL), provider, session)
_prune(now)
return session
finally:
with _guard:
flight.users -= 1
if flight.users == 0:
_flights.pop(key, None)
def refresh_session_coalesced(
token: str, provider_hint: str, *, phase: str, log: logging.Logger,
on_rejected: Optional[Callable[[DashboardAuthProvider], None]] = None,
on_unreachable: Optional[Callable[[DashboardAuthProvider], None]] = None,
) -> Optional[tuple[Session, str]]:
"""Rotate ``token`` through the provider stack with per-provider single-flight.
``(Session, provider_name)`` or ``None`` when every provider rejects the token; a
``ProviderError`` propagates when nothing rotated and one provider was unreachable
(``scan_session_providers`` semantics, so callers keep their 503-not-relogin handling).
Synchronous and network-bound: async callers run it in a threadpool.
"""
def _call(provider: DashboardAuthProvider):
session = _refresh_provider(provider, token)
if session is None:
if on_rejected is not None:
on_rejected(provider)
return None
return session, provider.name
return scan_session_providers(
provider_hint, _call, phase=phase, log=log, on_unreachable=on_unreachable)