test/docs(nous): trim keepalive tests to the two invariants; document nous.keepalive_interval_seconds
This commit is contained in:
@@ -1,33 +1,9 @@
|
|||||||
from hermes_cli import nous_auth_keepalive as keepalive
|
from hermes_cli import nous_auth_keepalive as keepalive
|
||||||
from hermes_cli.auth import ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
|
||||||
|
|
||||||
# Both lifetimes have been observed on real installs.
|
# Both lifetimes have been observed on real installs.
|
||||||
OBSERVED_LIFETIMES_SECONDS = (3594, 899)
|
OBSERVED_LIFETIMES_SECONDS = (3594, 899)
|
||||||
|
|
||||||
|
|
||||||
def test_resolved_tick_fits_inside_the_token_lifetime():
|
|
||||||
"""The tick actually used must land before the credential rolls over.
|
|
||||||
|
|
||||||
A tick at or above TTL - skew can miss the refresh window entirely, which
|
|
||||||
is what made every hour expire into a 401 plus a re-auth round trip.
|
|
||||||
|
|
||||||
This asserts on the derived tick rather than the configured constant,
|
|
||||||
because the constant is only a ceiling -- the schedule that ships is
|
|
||||||
whatever the derivation produces. It therefore fails if the derivation
|
|
||||||
constants regress (a lower TICKS_PER_LIFETIME or a higher
|
|
||||||
MIN_INTERVAL_SECONDS both break it), which a bare inequality against the
|
|
||||||
default interval cannot catch.
|
|
||||||
"""
|
|
||||||
for lifetime in OBSERVED_LIFETIMES_SECONDS:
|
|
||||||
tick = keepalive._tick_seconds(
|
|
||||||
keepalive.NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS, lifetime
|
|
||||||
)
|
|
||||||
assert tick < lifetime - ACCESS_TOKEN_REFRESH_SKEW_SECONDS, (
|
|
||||||
f"tick={tick}s leaves no room to refresh inside a {lifetime}s "
|
|
||||||
f"lifetime (skew={ACCESS_TOKEN_REFRESH_SKEW_SECONDS}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_always_fires_before_expiry_for_observed_lifetimes():
|
def test_refresh_always_fires_before_expiry_for_observed_lifetimes():
|
||||||
"""Simulate the tick schedule and assert no credential expires unrefreshed.
|
"""Simulate the tick schedule and assert no credential expires unrefreshed.
|
||||||
|
|
||||||
@@ -60,54 +36,6 @@ def test_refresh_always_fires_before_expiry_for_observed_lifetimes():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_tick_derives_from_observed_lifetime():
|
|
||||||
default = keepalive.NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS
|
|
||||||
|
|
||||||
# A short-lived credential pulls the tick down below the configured default.
|
|
||||||
assert keepalive._tick_seconds(default, 899) == 899 // 4
|
|
||||||
|
|
||||||
# A long-lived one is still capped by the configured interval.
|
|
||||||
assert keepalive._tick_seconds(default, 86400) == default
|
|
||||||
|
|
||||||
# A missing or nonsensical lifetime leaves the configured tick alone.
|
|
||||||
assert keepalive._tick_seconds(default, None) == default
|
|
||||||
assert keepalive._tick_seconds(default, 0) == default
|
|
||||||
|
|
||||||
# A pathological lifetime cannot spin the thread.
|
|
||||||
assert (
|
|
||||||
keepalive._tick_seconds(default, 4)
|
|
||||||
== keepalive.NOUS_AUTH_KEEPALIVE_MIN_INTERVAL_SECONDS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_horizon_covers_the_gap_between_ticks():
|
|
||||||
# A credential that will not survive until the next tick refreshes now.
|
|
||||||
assert keepalive._refresh_horizon_seconds(900, 120) == 900 + (
|
|
||||||
ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
|
||||||
)
|
|
||||||
# The caller's floor still applies when ticks are very frequent.
|
|
||||||
assert keepalive._refresh_horizon_seconds(10, 5000) == 5000
|
|
||||||
|
|
||||||
|
|
||||||
def test_observed_lifetime_takes_the_shorter_credential(monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
keepalive,
|
|
||||||
"get_provider_auth_state",
|
|
||||||
lambda provider: {"expires_in": 3594, "agent_key_expires_in": 899},
|
|
||||||
)
|
|
||||||
assert keepalive._observed_lifetime_seconds() == 899
|
|
||||||
|
|
||||||
monkeypatch.setattr(keepalive, "get_provider_auth_state", lambda provider: {})
|
|
||||||
assert keepalive._observed_lifetime_seconds() is None
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
keepalive,
|
|
||||||
"get_provider_auth_state",
|
|
||||||
lambda provider: {"expires_in": "nonsense"},
|
|
||||||
)
|
|
||||||
assert keepalive._observed_lifetime_seconds() is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_interval_precedence_and_disable(monkeypatch):
|
def test_interval_precedence_and_disable(monkeypatch):
|
||||||
def _config(section):
|
def _config(section):
|
||||||
monkeypatch.setattr(keepalive, "_nous_config", lambda: section)
|
monkeypatch.setattr(keepalive, "_nous_config", lambda: section)
|
||||||
@@ -137,20 +65,6 @@ def test_interval_precedence_and_disable(monkeypatch):
|
|||||||
assert keepalive.start_nous_auth_keepalive() is None
|
assert keepalive.start_nous_auth_keepalive() is None
|
||||||
|
|
||||||
|
|
||||||
def test_interval_survives_an_unreadable_config(monkeypatch):
|
|
||||||
"""A broken config.yaml must not take the keepalive thread down with it."""
|
|
||||||
|
|
||||||
def _boom():
|
|
||||||
raise RuntimeError("config.yaml is unreadable")
|
|
||||||
|
|
||||||
monkeypatch.setattr("hermes_cli.config.load_config", _boom)
|
|
||||||
assert keepalive._nous_config() == {}
|
|
||||||
assert (
|
|
||||||
keepalive._interval_seconds(None)
|
|
||||||
== keepalive.NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_keepalive_refreshes_stale_pool_entry(monkeypatch):
|
def test_keepalive_refreshes_stale_pool_entry(monkeypatch):
|
||||||
class _Entry:
|
class _Entry:
|
||||||
access_token = "pooled-access-token"
|
access_token = "pooled-access-token"
|
||||||
|
|||||||
@@ -237,6 +237,13 @@ The OAuth refresh token is stored separately at `~/.hermes/auth.json` (not in `c
|
|||||||
|
|
||||||
Hermes mints a short-lived JWT from your stored Portal refresh token on each inference call rather than reusing a long-lived API key. The token lifecycle is fully automatic — refresh, mint, retry on transient 401 — and you never see it.
|
Hermes mints a short-lived JWT from your stored Portal refresh token on each inference call rather than reusing a long-lived API key. The token lifecycle is fully automatic — refresh, mint, retry on transient 401 — and you never see it.
|
||||||
|
|
||||||
|
Long-running gateway and dashboard processes also run a background keepalive that refreshes the token before it expires, so idle agents don't pay a 401 round-trip on their first request of each credential lifetime. The keepalive derives its tick from the lifetime the Portal actually issued (several ticks per lifetime), bounded above by:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nous:
|
||||||
|
keepalive_interval_seconds: 900 # upper bound on the tick; 0 disables the keepalive
|
||||||
|
```
|
||||||
|
|
||||||
If the Portal invalidates the refresh token (password change, manual revoke, session expiry), the invalid refresh token is **quarantined locally** so Hermes stops replaying it and you don't see a stream of identical 401s. The next call surfaces a clear "re-authentication required" message. Run `hermes auth add nous` to log in again; the quarantine clears on the next successful login.
|
If the Portal invalidates the refresh token (password change, manual revoke, session expiry), the invalid refresh token is **quarantined locally** so Hermes stops replaying it and you don't see a stream of identical 401s. The next call surfaces a clear "re-authentication required" message. Run `hermes auth add nous` to log in again; the quarantine clears on the next successful login.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
Reference in New Issue
Block a user