Credential status — usable or stopped#
A credential is either usable, or stopped for a reason the user must act on. There is no user-visible "cooling" state.
Why not a cooling window#
A pay-as-you-go API key has no natural cooling state, so collapsing different
failures into one cooldown_until_ms window gives each of them the wrong
behaviour:
- a 404 (model not found) would cool the whole key, punishing every other model on that key for one bad request;
- a 402 (out of credits) would cool the key for hours, but credits do not come back by waiting — the user has to top up;
- a 5xx would cool the key, but an upstream outage says nothing about the key.
Only subscription and quota accounts (Claude Pro windows, free-tier models) genuinely have "wait until reset" semantics, and that is a different concept from key health.
What other frameworks do#
- OpenClaw (our pool's ancestor) keeps THREE separate states:
cooldownUntil(transient 429, laddered 30s→1m→5m),disabledUntil(402 billing / permanent auth — disabled, exponential backoff), andblockedUntil(subscription quota with a real reset timestamp from the usage API). 5xx never touches profile health; OpenRouter is explicitly exempted from cooldowns. - opencode: no key pool, no cooldown. Errors are request-level — 429/5xx retried twice with exponential backoff, everything else is a structured error returned to the caller.
- Claude Code: single account. Every failure is a chat-side message with an action (402 → top up, 401 → /login, 404 → /model, quota → reset countdown + options menu). Settings show no transient state.
The status model#
User-visible status (persisted, shown in the accounts panel):
| status | meaning | recovery |
|---|---|---|
valid |
usable | — |
billing_blocked |
402 — stopped, out of credits | top up, then Validate (success auto-restores valid) |
needs_reauth |
401/403 — stopped, credential rejected | re-add key / sign in |
revoked |
permanently dead | replace |
rate_limited |
429 — briefly throttled | auto-restores on next success / window expiry |
The status column carries the whole answer, so no separate badge is needed.
A rate_limited credential whose window has expired reports as valid.
Internal scheduling (never shown):
- 429 keeps a short
cooldown_until_msso multi-key rotation skips the throttled key; single-key setups still send (better than nothing). - 5xx and network errors do not touch the credential — transport failures say nothing about key health, matching OpenClaw's semantics.
- Request-level 4xx (404/400/422) do not touch the credential; they are
request_error.
Chat side: stream errors render as red error bubbles carrying the provider's own message ("Insufficient Balance", …). That is the user-facing notification; the accounts panel is only for diagnosis.
How the status is maintained#
auth/usage.py record_call_failure— onlyrate_limit,rate_limit_long,billing_blocked, andneeds_reauthreach the pool;request_error,server_error, andnetwork_errorreturn without touching it.auth/pool.py mark_failure—billing_blockedsets the status with no cooldown timestamp: stopped until re-validated, rather than a timed wait.auth/pool.pyauto-restore — onlyrate_limitedself-heals;billing_blockedis excluded, so validation is the only way back.auth/usage.py _account_healthy—billing_blockedcounts as unhealthy for rotation alongsiderevokedandneeds_reauth.webui/routes/accounts.py— a successful Validate writesstatus="valid"and clears the cooldown andlast_error, which closes the top-up → Validate → restored loop; the account record carries nocoolingfield, and arate_limitedcredential past its window reportsvalid.web .. account-manager.tsx— status renders as valid / rate limited / billing blocked / needs re-authentication / revoked.