refactor(honcho): dedupe refresh-failure handling; harden exchange budget, dogpile cooldown, and rebuild race

Follow-ups from review of #80590:

- oauth.py: extract _rotate_and_persist() — the twin ~18-line
  OAuthRefreshError permanent/transient handling blocks in
  ensure_fresh_token and force_refresh_token were byte-identical
  except the log verb.
- oauth.py: cap the exchange cycle at _REFRESH_TOTAL_BUDGET_SECONDS
  (20s). The retry runs while holding the global refresh locks on the
  path to a memory call; a timed-out first attempt no longer earns a
  second full 15s exchange (~32s lock hold -> <=20s).
- oauth.py: transient-failure cooldown (_refresh_failure_at, 30s).
  Waiting threads and later turns fail open to the stale token instead
  of serializing their own full exchange cycles against an endpoint
  that just failed. Cleared on successful rotation and re-login.
- oauth.py: mtime-gate reauth_required()'s config read — the dead-grant
  state persists until re-login, and the verdict can only change when
  the config file is rewritten; drop the per-call read+parse.
- oauth.py: derive _TOKEN_VALUE_RE from ACCESS_TOKEN_PREFIX /
  REFRESH_TOKEN_PREFIX so a prefix change can't silently break
  redaction; promote redact_tokens to public (session.py imported the
  private name).
- session.py: fast path in _reauth_required — skip config-path
  resolution entirely while no grant is dead (runs before every SDK
  call).
- session.py: client-generation counter closes the fetch/store race in
  _sdk_session/_get_or_create_peer — an object resolved from the old
  client mid-rebuild is no longer cached (it would 401 forever and burn
  a token rotation per retry).
- __init__.py: drop the getattr/callable/except triple-guard in
  _pop_auth_notice; the manager is always None or HonchoSessionManager.

7 new tests (budget, cooldown x3, generation guard, fast path); all
mutation-checked (disabling each guard fails its test). honcho_plugin
293 passed; plugins/memory 285 passed; live E2E against a real HTTP
token endpoint re-verified.
This commit is contained in:
kshitij 2026-08-08 14:21:12 +05:30
parent 086dc8b880
commit edfe4f5136
4 changed files with 330 additions and 66 deletions

View File

@ -883,13 +883,7 @@ class HonchoMemoryProvider(MemoryProvider):
def _pop_auth_notice(self) -> str:
"""One-time model-facing notice that Honcho auth expired and memory is paused."""
msg = None
pop = getattr(self._manager, "pop_auth_notice", None)
if callable(pop):
try:
msg = pop()
except Exception:
msg = None
msg = self._manager.pop_auth_notice() if self._manager else None
if not isinstance(msg, str) or not msg:
# Init failures discard the manager; the provider kept the detail.
if self._init_auth_failure is None or self._init_auth_notice_emitted:

View File

@ -45,16 +45,35 @@ _REFRESH_TIMEOUT_SECONDS = 15.0
# Retry pause, kept short: the server honors a replayed refresh token only briefly after rotating it.
_REFRESH_RETRY_DELAY_SECONDS = 2.0
# Total wall-clock budget for one exchange cycle (first attempt + pause + retry).
# The exchange runs while holding the global refresh locks on the path to a
# memory call, so a stalled token endpoint must not hold them for two full
# HTTP timeouts back to back.
_REFRESH_TOTAL_BUDGET_SECONDS = 20.0
# After a transient exchange failure, fail open without re-exchanging for this
# long. Prevents N waiting threads (or turns) from serializing N full exchange
# cycles against an endpoint that just failed.
_REFRESH_FAILURE_COOLDOWN_SECONDS = 30.0
# OAuth error codes that a retry can never fix — the grant itself is dead.
_PERMANENT_OAUTH_ERRORS = frozenset({"invalid_grant", "invalid_client", "unauthorized_client"})
# Token values are secret even though their prefixes are not; redact before logging.
_TOKEN_VALUE_RE = re.compile(r"hch-(at|rt)-[A-Za-z0-9._~+/=-]+")
# Derived from the canonical prefixes above so a prefix change can't silently
# break redaction.
_TOKEN_VALUE_RE = re.compile(
rf"({re.escape(ACCESS_TOKEN_PREFIX)}|{re.escape(REFRESH_TOKEN_PREFIX)})[A-Za-z0-9._~+/=-]+"
)
def _redact_tokens(text: str) -> str:
def redact_tokens(text: str) -> str:
"""Replace any embedded token values with their prefix plus a placeholder."""
return _TOKEN_VALUE_RE.sub(lambda m: f"hch-{m.group(1)}-[redacted]", text)
return _TOKEN_VALUE_RE.sub(lambda m: f"{m.group(1)}[redacted]", text)
# Backward-compat alias for oauth-internal call sites and older importers.
_redact_tokens = redact_tokens
class OAuthRefreshError(Exception):
@ -132,6 +151,26 @@ _expiry_cache: dict[tuple[str, str], tuple[float, str]] = {}
# Permanently rejected grants: (config path, host) → sha256 of the dead refresh token; a re-login rotates the token, so the digest check self-clears.
_dead_grants: dict[tuple[str, str], str] = {}
# Last transient exchange failure per grant: key → monotonic timestamp. While
# inside the cooldown window callers fail open to the stale token without
# re-exchanging, so waiting threads don't serialize repeated full exchange
# cycles against an endpoint that just failed.
_refresh_failure_at: dict[tuple[str, str], float] = {}
def _in_failure_cooldown(key: tuple[str, str]) -> bool:
failed_at = _refresh_failure_at.get(key)
return (
failed_at is not None
and (time.monotonic() - failed_at) < _REFRESH_FAILURE_COOLDOWN_SECONDS
)
# Memoized reauth_required verdict per grant: key → (config mtime_ns, result).
# The verdict only changes when the config file is rewritten (re-login), so an
# unchanged mtime short-circuits the read+parse on the dead-grant hot path.
_reauth_check_cache: dict[tuple[str, str], tuple[int, bool]] = {}
def _refresh_token_digest(cred: OAuthCredential) -> str:
return hashlib.sha256(cred.refresh_token.encode("utf-8")).hexdigest()
@ -143,6 +182,8 @@ def _grant_is_dead(key: tuple[str, str], cred: OAuthCredential) -> bool:
def _mark_grant_dead(key: tuple[str, str], cred: OAuthCredential) -> None:
_dead_grants[key] = _refresh_token_digest(cred)
# The verdict changed without a config rewrite; drop any memoized answer.
_reauth_check_cache.pop(key, None)
def reauth_required(path: Path, host: str) -> bool:
@ -150,9 +191,29 @@ def reauth_required(path: Path, host: str) -> bool:
key = (str(path), host)
if key not in _dead_grants:
return False
# A re-login rewrites the config file, so gate the read+parse on mtime:
# while the file is unchanged the answer cannot change.
try:
mtime = path.stat().st_mtime_ns
except OSError:
mtime = -1
cached = _reauth_check_cache.get(key)
if cached is not None and cached[0] == mtime:
return cached[1]
block = (_read_config(path).get("hosts") or {}).get(host) or {}
cred = OAuthCredential.from_host_block(block)
return cred is not None and _grant_is_dead(key, cred)
result = cred is not None and _grant_is_dead(key, cred)
_reauth_check_cache[key] = (mtime, result)
return result
def any_dead_grants() -> bool:
"""Cheap predicate: has any grant in this process been marked dead?
Lets hot-path callers skip config-path resolution entirely in the
overwhelmingly common healthy state.
"""
return bool(_dead_grants)
def is_oauth_access_token(value: str | None) -> bool:
@ -260,7 +321,9 @@ def _http_get_json(url: str, timeout: float) -> dict[str, Any]:
return body if isinstance(body, dict) else {}
def _exchange_refresh_token(cred: OAuthCredential, *, now: float) -> OAuthCredential:
def _exchange_refresh_token(
cred: OAuthCredential, *, now: float, timeout: float = _REFRESH_TIMEOUT_SECONDS
) -> OAuthCredential:
"""Run the refresh_token grant and return the rotated credential.
Raises ``OAuthRefreshError`` (with the endpoint's error body) on an error
@ -273,7 +336,7 @@ def _exchange_refresh_token(cred: OAuthCredential, *, now: float) -> OAuthCreden
"client_id": cred.client_id,
"refresh_token": cred.refresh_token,
},
_REFRESH_TIMEOUT_SECONDS,
timeout,
)
if status >= 400:
error = str(body.get("error") or "")
@ -306,8 +369,13 @@ def _exchange_refresh_token(cred: OAuthCredential, *, now: float) -> OAuthCreden
def _exchange_with_retry(cred: OAuthCredential, *, now: float) -> OAuthCredential:
"""Exchange the refresh token, retrying once on transient failure.
The server accepts a replayed token only briefly after rotating it, so the retry cannot wait.
The server accepts a replayed token only briefly after rotating it, so the
retry cannot wait — and the whole cycle is capped by
``_REFRESH_TOTAL_BUDGET_SECONDS`` because it runs under the global refresh
locks: a fast first failure gets a full-timeout retry, a slow (timed-out)
first attempt gets only the remaining budget.
"""
deadline = time.monotonic() + _REFRESH_TOTAL_BUDGET_SECONDS
try:
return _exchange_refresh_token(cred, now=now)
except OAuthRefreshError as exc:
@ -316,12 +384,55 @@ def _exchange_with_retry(cred: OAuthCredential, *, now: float) -> OAuthCredentia
first: Exception = exc
except Exception as exc:
first = exc
remaining = deadline - time.monotonic() - _REFRESH_RETRY_DELAY_SECONDS
if remaining <= 0:
raise first
logger.warning(
"Honcho OAuth token exchange failed, retrying once: %s",
_redact_tokens(str(first)),
)
time.sleep(_REFRESH_RETRY_DELAY_SECONDS)
return _exchange_refresh_token(cred, now=now)
return _exchange_refresh_token(
cred, now=now, timeout=min(remaining, _REFRESH_TIMEOUT_SECONDS)
)
def _rotate_and_persist(
path: Path,
host: str,
key: tuple[str, str],
cred: OAuthCredential,
*,
now: float,
op_label: str = "refresh",
) -> OAuthCredential | None:
"""Exchange ``cred`` and persist the rotation; ``None`` on failure (logged).
A permanent OAuth error marks the grant dead so later calls skip the
endpoint until a new login rotates the refresh token.
"""
try:
rotated = _exchange_with_retry(cred, now=now)
except OAuthRefreshError as exc:
if exc.permanent:
_mark_grant_dead(key, cred)
logger.error(
"Honcho OAuth grant for host %s is no longer valid (%s); "
"run 'hermes honcho setup' to re-authenticate", host, exc,
)
else:
_refresh_failure_at[key] = time.monotonic()
logger.warning("Honcho OAuth %s failed for host %s: %s", op_label, host, exc)
return None
except Exception as exc:
_refresh_failure_at[key] = time.monotonic()
logger.warning(
"Honcho OAuth %s failed for host %s: %s",
op_label, host, _redact_tokens(str(exc)),
)
return None
_persist_credential(path, host, rotated)
return rotated
def _read_config(path: Path) -> dict[str, Any]:
@ -366,6 +477,7 @@ def _persist_credential(path: Path, host: str, cred: OAuthCredential) -> None:
_atomic_write_config(path, raw)
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
_dead_grants.pop((str(path), host), None)
_refresh_failure_at.pop((str(path), host), None)
def ensure_fresh_token(
@ -404,6 +516,9 @@ def ensure_fresh_token(
_expiry_cache[key] = (cred.expires_at, cred.access_token)
if not cred.is_expired(now=now):
return cred.access_token, False
if _in_failure_cooldown(key):
# An exchange just failed transiently; don't pile on the endpoint.
return cred.access_token, False
with _refresh_lock, _config_refresh_lock(path):
# Re-read under both locks: another thread or process may have just
@ -414,25 +529,12 @@ def ensure_fresh_token(
return current.access_token, current.access_token != cred.access_token
if _grant_is_dead(key, current):
return current.access_token, False
try:
rotated = _exchange_with_retry(current, now=now)
except OAuthRefreshError as exc:
if exc.permanent:
_mark_grant_dead(key, current)
logger.error(
"Honcho OAuth grant for host %s is no longer valid (%s); "
"run 'hermes honcho setup' to re-authenticate", host, exc,
)
else:
logger.warning("Honcho OAuth refresh failed for host %s: %s", host, exc)
if _in_failure_cooldown(key):
# The lock holder we waited on just failed; fail open too.
return current.access_token, False
except Exception as exc:
logger.warning(
"Honcho OAuth refresh failed for host %s: %s",
host, _redact_tokens(str(exc)),
)
rotated = _rotate_and_persist(path, host, key, current, now=now)
if rotated is None:
return current.access_token, False
_persist_credential(path, host, rotated)
logger.info("Honcho OAuth token refreshed for host %s", host)
return rotated.access_token, True
@ -452,30 +554,18 @@ def force_refresh_token(path: Path, host: str) -> str | None:
return None
if _grant_is_dead(key, cred):
return None
if _in_failure_cooldown(key):
# An exchange just failed transiently; don't force another full
# cycle — callers fail open and retry after the cooldown.
return None
cached = _expiry_cache.get(key)
# Another thread or process already rotated: adopt the newer on-disk token.
if cached is not None and cred.access_token != cached[1] and not cred.is_expired(now=now):
_expiry_cache[key] = (cred.expires_at, cred.access_token)
return cred.access_token
try:
rotated = _exchange_with_retry(cred, now=now)
except OAuthRefreshError as exc:
if exc.permanent:
_mark_grant_dead(key, cred)
logger.error(
"Honcho OAuth grant for host %s is no longer valid (%s); "
"run 'hermes honcho setup' to re-authenticate", host, exc,
)
else:
logger.warning("Honcho OAuth forced refresh failed for host %s: %s", host, exc)
rotated = _rotate_and_persist(path, host, key, cred, now=now, op_label="forced refresh")
if rotated is None:
return None
except Exception as exc:
logger.warning(
"Honcho OAuth forced refresh failed for host %s: %s",
host, _redact_tokens(str(exc)),
)
return None
_persist_credential(path, host, rotated)
logger.info("Honcho OAuth token force-refreshed for host %s after an auth failure", host)
return rotated.access_token
@ -526,6 +616,7 @@ def install_grant(
_deep_merge(raw, granted_config)
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
_dead_grants.pop((str(path), host), None)
_refresh_failure_at.pop((str(path), host), None)
hosts = raw.setdefault("hosts", {})
block = hosts.setdefault(host, {})
block["apiKey"] = cred.access_token

View File

@ -12,7 +12,7 @@ from datetime import datetime
from typing import Any, Callable, TYPE_CHECKING
from plugins.memory.honcho.client import get_honcho_client
from plugins.memory.honcho.oauth import _redact_tokens
from plugins.memory.honcho.oauth import redact_tokens as _redact_tokens
if TYPE_CHECKING:
from honcho import Honcho
@ -150,6 +150,10 @@ class HonchoSessionManager:
self._cache_lock = threading.RLock()
self._peers_cache: dict[str, Any] = {}
self._sessions_cache: dict[str, Any] = {}
# Bumped (under _cache_lock) whenever _force_reauth rebuilds the client.
# In-flight resolvers compare it around their SDK fetch so an object
# bound to the discarded client is never stored into the fresh cache.
self._client_generation = 0
# Set when a call still fails auth after a forced token refresh; cleared on the next success.
self._auth_failure: str | None = None
@ -241,6 +245,12 @@ class HonchoSessionManager:
"""
try:
from plugins.memory.honcho import oauth
# Fast path: no grant in this process is dead — skip config-path
# resolution entirely (this runs before every SDK call).
if not oauth.any_dead_grants():
return False
from plugins.memory.honcho.client import resolve_config_path
host = getattr(self._config, "host", "") or ""
@ -272,6 +282,7 @@ class HonchoSessionManager:
# SDK shape changed: rebuild the client and drop objects holding the old transport.
reset_honcho_client()
with self._cache_lock:
self._client_generation += 1
self._peers_cache.clear()
self._sessions_cache.clear()
return True
@ -315,25 +326,32 @@ class HonchoSessionManager:
def _sdk_session(self, session_id: str) -> Any:
"""Get or create the SDK session; a client rebuild clears the cache, so re-fetch."""
with self._cache_lock:
cached = self._sessions_cache.get(session_id)
if cached is not None:
return cached
sdk_session = self.honcho.session(session_id)
with self._cache_lock:
self._sessions_cache[session_id] = sdk_session
return sdk_session
while True:
with self._cache_lock:
cached = self._sessions_cache.get(session_id)
generation = self._client_generation
if cached is not None:
return cached
sdk_session = self.honcho.session(session_id)
with self._cache_lock:
if self._client_generation == generation:
return self._sessions_cache.setdefault(session_id, sdk_session)
# The client was rebuilt while we resolved; this object holds the
# discarded transport. Don't cache it — resolve afresh.
def _get_or_create_peer(self, peer_id: str) -> Any:
"""Get or create a Honcho peer (one get-or-create API call, then cached)."""
with self._cache_lock:
if peer_id in self._peers_cache:
return self._peers_cache[peer_id]
while True:
with self._cache_lock:
if peer_id in self._peers_cache:
return self._peers_cache[peer_id]
generation = self._client_generation
peer = self._authed_call("peer setup", lambda: self.honcho.peer(peer_id))
with self._cache_lock:
self._peers_cache[peer_id] = peer
return peer
peer = self._authed_call("peer setup", lambda: self.honcho.peer(peer_id))
with self._cache_lock:
if self._client_generation == generation:
return self._peers_cache.setdefault(peer_id, peer)
# Client rebuilt mid-resolve — drop the stale object and retry.
def _get_or_create_honcho_session(
self, session_id: str, user_peer: Any, assistant_peer: Any

View File

@ -985,3 +985,164 @@ class TestInitAuthFailureNotice:
out = provider.handle_tool_call("honcho_profile", {})
assert "could not be initialized" in out
# ---------------------------------------------------------------------------
# hardening: exchange budget, failure cooldown, client-generation cache guard
# ---------------------------------------------------------------------------
class TestExchangeBudget:
def test_timed_out_first_attempt_skips_retry_when_budget_spent(self, tmp_path, monkeypatch):
"""A first attempt that consumed the whole budget must not start a
second full-timeout exchange while holding the global refresh locks."""
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block()}})
monkeypatch.setattr(oauth, "_REFRESH_RETRY_DELAY_SECONDS", 0)
calls = []
clock = {"now": 1000.0}
monkeypatch.setattr(oauth.time, "monotonic", lambda: clock["now"])
def slow_timeout(url, data, timeout):
calls.append(timeout)
clock["now"] += oauth._REFRESH_TOTAL_BUDGET_SECONDS + 1
raise TimeoutError("token exchange timed out")
monkeypatch.setattr(oauth, "_http_post_form_status", slow_timeout)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
assert token == "hch-at-old" and refreshed is False
assert len(calls) == 1 # no second exchange after the budget is gone
def test_fast_failure_retry_gets_remaining_budget(self, tmp_path, monkeypatch):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block()}})
monkeypatch.setattr(oauth, "_REFRESH_RETRY_DELAY_SECONDS", 0)
timeouts = []
def flaky(url, data, timeout):
timeouts.append(timeout)
if len(timeouts) == 1:
raise ConnectionError("reset")
return 200, _rotated_body()
monkeypatch.setattr(oauth, "_http_post_form_status", flaky)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
assert refreshed is True and token == "hch-at-new1"
assert len(timeouts) == 2
# Retry timeout is bounded by both the per-attempt cap and the budget.
assert 0 < timeouts[1] <= oauth._REFRESH_TIMEOUT_SECONDS
class TestFailureCooldown:
def test_repeated_calls_within_cooldown_do_not_reexchange(self, tmp_path, monkeypatch):
"""After a transient failure, waiting callers fail open instead of
serializing their own full exchange cycles (dogpile guard)."""
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block()}})
monkeypatch.setattr(oauth, "_REFRESH_RETRY_DELAY_SECONDS", 0)
calls = []
def boom(*a, **k):
calls.append(1)
raise ConnectionError("network down")
monkeypatch.setattr(oauth, "_http_post_form_status", boom)
oauth.ensure_fresh_token(path, "hermes", now=1000)
assert len(calls) == 2 # first attempt + its one retry
# Subsequent callers inside the cooldown window skip the endpoint.
for _ in range(3):
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
assert token == "hch-at-old" and refreshed is False
assert oauth.force_refresh_token(path, "hermes") is None
assert len(calls) == 2
# After the cooldown expires the exchange is attempted again.
key = (str(path), "hermes")
oauth._refresh_failure_at[key] -= oauth._REFRESH_FAILURE_COOLDOWN_SECONDS + 1
oauth.ensure_fresh_token(path, "hermes", now=1000)
assert len(calls) == 4
def test_relogin_clears_the_cooldown(self, tmp_path, monkeypatch):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block()}})
monkeypatch.setattr(oauth, "_REFRESH_RETRY_DELAY_SECONDS", 0)
def boom(*a, **k):
raise ConnectionError("network down")
monkeypatch.setattr(oauth, "_http_post_form_status", boom)
oauth.ensure_fresh_token(path, "hermes", now=1000)
assert oauth._in_failure_cooldown((str(path), "hermes")) is True
_relogin(path)
assert oauth._in_failure_cooldown((str(path), "hermes")) is False
def test_successful_rotation_clears_the_cooldown(self, tmp_path, monkeypatch):
path = tmp_path / "honcho.json"
_write(path, {"hosts": {"hermes": _host_block()}})
monkeypatch.setattr(oauth, "_REFRESH_RETRY_DELAY_SECONDS", 0)
key = (str(path), "hermes")
oauth._refresh_failure_at[key] = (
oauth.time.monotonic() - oauth._REFRESH_FAILURE_COOLDOWN_SECONDS - 1
)
monkeypatch.setattr(
oauth, "_http_post_form_status", lambda *a, **k: (200, _rotated_body())
)
token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000)
assert refreshed is True
assert key not in oauth._refresh_failure_at
class TestClientGenerationGuard:
def test_stale_object_resolved_across_rebuild_is_not_cached(self, monkeypatch):
"""A resolver that fetched from the OLD client must not store its
object into the cache after _force_reauth rebuilt the client."""
from plugins.memory.honcho import session as session_mod
cfg = HonchoClientConfig(host="hermes", api_key="hch-at-x", enabled=True)
mgr = HonchoSessionManager(config=cfg)
stale_session = object()
fresh_session = object()
resolutions = []
class _Client:
def session(self, sid):
# First resolve returns the stale object and simulates a
# concurrent rebuild landing mid-flight; the retry gets fresh.
if not resolutions:
resolutions.append("stale")
with mgr._cache_lock:
mgr._client_generation += 1
mgr._sessions_cache.clear()
return stale_session
resolutions.append("fresh")
return fresh_session
client = _Client()
monkeypatch.setattr(session_mod, "get_honcho_client", lambda *a, **k: client)
got = mgr._sdk_session("s")
assert got is fresh_session
assert mgr._sessions_cache["s"] is fresh_session
assert resolutions == ["stale", "fresh"]
def test_dead_grant_check_fast_path_skips_path_resolution(self, monkeypatch):
"""With no dead grants, _reauth_required must not resolve the config
path at all (it runs before every SDK call)."""
from plugins.memory.honcho import client as client_mod
oauth._dead_grants.clear()
def _fail():
raise AssertionError("resolve_config_path must not run on the fast path")
monkeypatch.setattr(client_mod, "resolve_config_path", _fail)
cfg = HonchoClientConfig(host="hermes", api_key="hch-at-x", enabled=True)
mgr = HonchoSessionManager(config=cfg)
assert mgr._reauth_required() is False