Skip to content

llm: degradation store cannot distinguish a dead credential from a transient failure — dead tokens retry every TTL forever #15022

Description

@mrveiss

Parent: #15021

Problem

llm_shared/provider_degradation.py stores one boolean per (provider, model) with a single
env-backed TTL (AUTOBOT_PROVIDER_DEGRADATION_TTL_SECONDS, default 300 s). An expired refresh token
and a transient socket error produce the identical state and the identical recovery.

The consequence: a credential that is permanently dead — revoked, expired beyond refresh, org access
removed — is retried every 300 s, forever. Nobody is told. Every retry costs a failed round-trip and
the caller pays the fallback latency, indefinitely.

TokenExpiredError already exists (llm_shared/provider_auth.py:85) and is raised by the auth
strategies, but nothing routes it into the degradation store, so the information is discarded at the
moment it is most useful.

Scope

  • llm_shared/provider_degradation.py — the stored Redis value gains a cause. needs_reauth is
    non-expiring: it clears on a successful re-auth, not on a TTL.
  • llm_shared/base_provider.py — catch TokenExpiredError / auth failures and mark with the
    needs_reauth cause rather than the generic degraded path.
  • Alerting — a needs_reauth transition raises an operator alert through the existing
    autobot_shared/alert_cooldown.py (multi-tier progressive cooldown, Feature: Multi-tier alert cooldown system to prevent notification fatigue #1948). Do not build a second
    notification path.
  • is_degraded() keeps its current signature and semantics for existing callers; the cause is
    additive.

Acceptance criteria

  • A provider marked needs_reauth is skipped by the registry and does not silently return to
    the pool after the degradation TTL.
  • A transient failure keeps today's exact behaviour: degraded for the TTL, then retried.
  • TokenExpiredError from any auth strategy (ApiKeyAuth, OAuthAuth, DeviceCodeAuth,
    SessionAuth) results in needs_reauth, not generic degradation.
  • A needs_reauth transition emits exactly one alert per credential per cooldown tier — verified
    against alert_cooldown, not a new de-dup set.
  • Clearing is explicit: a successful auth for that provider clears the state.
  • degraded_entries() reports the cause so the observability path can show why.
  • Redis-unavailable fallback (the existing in-process dict) preserves the new behaviour.
  • Tests in provider_degradation_test.py cover: transient expiry, needs_reauth non-expiry,
    explicit clear, and the Redis-down path.

Implementation Order

Wave: 1
Depends on: none
Unblocks: #15021 Task 4

Activity

  1. mrveiss commented on Aug 30, 2026

    @mrveiss
    OwnerAuthor

    PR #15320 delivers the classifier. Two criteria are not met, and one of them is blocked by a contradiction in the existing design.

    Verified the PR's central claims directly rather than taking them on trust:

    • TokenExpiredError has exactly two raise sites — provider_auth.py:334 (OAuth: expired with no refresh token) and :508 (Session: expired, reconnect). Both are pure local checks against vault state with no network round trip. OAuthAuth._refresh's HTTP failures raise plain ProviderAuthError and are untouched. So the classifier cannot misfire on a timeout or a flaky connection, and the "permanent" label is earned rather than guessed. This was the thing most worth checking, because the whole low-risk argument rests on it.
    • The fix is currently inert. _get_auth_token() is defined at base_provider.py:326 and referenced by nothing except tests — every concrete provider (openai, anthropic, groq, mistral) reads settings["api_key"] directly. So TokenExpiredError cannot fire on any live request today. The PR is correct infrastructure for a path nothing yet takes, which is consistent with Umbrella: provider quota headroom + multi-account failover — finish the declared-but-unbuilt quota monitor #15021's own "declared but unbuilt" framing, but it means no runtime behaviour changes on merge.

    Criteria status

    AC Verdict
    2. Transient failure keeps today's behaviour (TTL, then retry) MET — only TokenExpiredError diverts; everything else stays on the existing path
    3. TokenExpiredError results in needs_reauth, not generic degradation MET at the store, but see the inertness note — no strategy is wired to a live request yet
    5. Clearing is explicit — a successful auth clears the state MET — new clear(), called from _get_auth_token() on a successful vault-backed resolve_token()
    6. degraded_entries() reports the cause MET
    1. A needs_reauth provider is skipped by the registry NOT MET — see below
    4. Exactly one alert per credential per cooldown tier, via alert_cooldown not verified
    7. Redis-unavailable fallback preserves the new behaviour not verified
    8. Tests cover transient expiry, non-expiry, explicit clear, Redis-down partially

    Why AC1 cannot be satisfied without a decision

    mark_degraded(provider, cause=NEEDS_REAUTH) writes the provider-only key. provider_registry.py:464 asks is_degraded(name, model_name) — with a model — and is_degraded (provider_degradation.py:93) builds _make_key(provider, model) and checks that key alone. A provider-level mark is therefore invisible to every model-scoped selection, which is the path the registry actually uses.

    That is not an oversight in the PR. It is an explicitly asserted invariant in test_provider_degradation.py:111:

    await store.mark_degraded("groq")
    assert await store.is_degraded("groq") is True
    # Model-scoped key must NOT match a provider-only mark.
    assert await store.is_degraded("groq", "llama3") is False

    So the codebase asserts that a provider-level mark does not imply its models, while AC1 requires exactly that implication for needs_reauth. Both cannot hold. The contradiction is worth stating plainly because the cheap fix — making is_degraded fall back to the provider key — would silently overturn a deliberate, tested invariant, and I am not willing to do that inside a review on my own judgement.

    The distinction that probably resolves it: a transient provider mark arguably should not condemn every model, but a dead credential logically does — one credential authenticates all of that provider's models. So the right change is likely narrower than the cheap fix: let a NEEDS_REAUTH mark imply its models, while leaving transient marks key-independent as today. That preserves the tested invariant for the case it was written for.

    I have not made that change. It needs a decision, and it is this issue's, not the PR's.

    Blast radius while AC1 is unmet

    Bounded, because the local-only check still fails fast on every attempt — no wasted network round trip, and no credential is wrongly parked. What is missing is the pre-flight registry skip for model-scoped requests, so a dead credential is rediscovered per call instead of being routed around. Given the path is inert today, nothing regresses.

    Keeping this open. The PR is worth merging as correct, additive infrastructure with honest limits; it does not close this issue.

  2. added
    needs-decisionBlocked on an owner decision; options and a recommendation are on the issue
    on Aug 30, 2026
  3. mrveiss commented on Aug 30, 2026

    @mrveiss
    OwnerAuthor

    PR #15320 merged. The classifier and its tests are in; this issue stays open on the criteria recorded above — AC1 (registry skip) is blocked by the tested key-independence invariant and needs the needs-decision call, and AC4/AC7 are unverified.

    Also unchanged: the path is inert until a concrete provider routes through _get_auth_token(). Nothing regresses, and nothing yet exercises it.

  4. added this to the v0.9.0 milestone on Sep 12, 2026
  5. modified the milestones: v0.9.0, v0.9-decisions on Sep 28, 2026
  6. mrveiss commented on Oct 3, 2026

    @mrveiss
    OwnerAuthor

    The needs-decision call, stated — it was labelled but never framed, so there was nothing to answer.

    What is already delivered

    llm_shared/provider_degradation.py on merged main has the cause split: DegradationCause.NEEDS_REAUTH at :83, non-expiring by construction (ttl = None if cause is DegradationCause.NEEDS_REAUTH else _DEGRADATION_TTL_SECONDS, :127), cause-aware degraded_entries(), the alert tier at :71, and tests/test_provider_degradation_reauth.py covering non-expiry, explicit clear, cause reporting and the TokenExpiredError wiring.

    What AC1 collides with

    AC1: "A provider marked needs_reauth is skipped by the registry and does not silently return to the pool after the degradation TTL."

    provider_registry.py:458-471 deliberately does the opposite in one case:

    # All candidates are checked up-front so we can detect the all-degraded
    # edge case and fall through rather than returning None.
    ...
    all_degraded = len(candidates) > 0 and degraded_set == set(candidates)
    if all_degraded:
        logger.warning("degradation: all %d candidates degraded — proceeding anyway", ...)

    and the skip at :474 is guarded by and not all_degraded.

    So when every candidate is needs_reauth, the registry uses them anyway. That is #11519's deliberate availability choice — never return None — and AC1 cannot be satisfied without changing it. The two are not reconcilable by implementation.

    The asymmetry that decides it

    The all_degraded fall-through is sound for TRANSIENT, because a transient mark may be stale: the socket error was 200 seconds ago and the provider may well be healthy, so trying beats refusing. That reasoning does not transfer to needs_reauth. A revoked or expired-beyond-refresh credential is not stale information — it is known-dead until an explicit re-auth clears it. Retrying is not an optimistic gamble, it is a guaranteed failed round-trip plus the caller's fallback latency, which is the exact cost this issue was filed about.

    Options

    A — exempt needs_reauth from the fall-through. Never select it; return None when it is all there is.
    Gets AC1 literally. Reverses #11519's guarantee for this case: a caller that gets a slow failure today gets a fast one, which is a different error shape and may need handling.

    B — keep the fall-through. needs_reauth changes TTL behaviour and alerting only, never selection.
    Preserves #11519 unchanged. AC1 as written becomes false and should be amended — the dead credential is still retried, just whenever it is the only option rather than every 300 s.

    C — exempt it, and fail with a typed, distinguishable error rather than a bare None: callers can tell "all credentials need re-auth" from "no capacity".

    Recommendation: C

    It gets AC1's intent — a known-dead credential is never silently retried — while answering the reason #11519 chose the fall-through, which was not "always try something" for its own sake but "do not hand the caller an unexplained None". A typed error satisfies both. A is correct in behaviour and leaves callers worse informed than today; B is honest but keeps the defect and should then amend AC1 rather than leave it unticked forever.

    Whichever is chosen, AC4 and AC7 remain unverified independently of this, and the path stays inert until a concrete provider routes through _get_auth_token().

    Framing only — no code change, and the choice is the owner's.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: company-osWave 4 · cluster A — Company OS & LLC canvasbackendenhancementNew feature or requestfrontendneeds-decisionBlocked on an owner decision; options and a recommendation are on the issuepriority: high

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions