Skip to content

Pre-emptively de-prefer a Claude credential on allowed_warning - #116

Open
chansearrington wants to merge 7 commits into
router-for-me:devfrom
chansearrington:feat/claude-preemptive-warning-cutover
Open

Pre-emptively de-prefer a Claude credential on allowed_warning#116
chansearrington wants to merge 7 commits into
router-for-me:devfrom
chansearrington:feat/claude-preemptive-warning-cutover

Conversation

@chansearrington

@chansearrington chansearrington commented Sep 10, 2026

Copy link
Copy Markdown

Pre-emptively de-prefer a Claude credential on allowed_warning

Today a Claude credential is only de-preferred after Anthropic rejects a request: something has
to fail before the router reacts, so a caller sees a real error on every window transition.

Anthropic already tells us in advance. On successful responses it sends
Anthropic-Ratelimit-Unified-{5h,7d,7d_oi}-Status: allowed_warning before it starts rejecting. This
PR reads that signal and starts preferring another credential while the warned one is still serving.

Behaviour

  • Per Claude credential, per window (5h, 7d, 7d_oi): a warning mark is set when a successful
    response's Anthropic-Ratelimit-Unified-<window>-Status is exactly allowed_warning, carrying
    that window's -Reset epoch when present.
  • The mark clears when that window's own status is exactly allowed, when the unsuffixed aggregate
    status reports allowed, or when the Reset epoch passes.
  • The selector prefers enabled, non-exhausted, non-warned credentials.
  • 🔴 It never starves. If every candidate is warned, the warned credential is still served from.
    A warning is a soft preference, never a block — no caller ever sees an error that the router
    could have avoided by just using the warned credential.
  • A disabled credential is never a candidate. The existing per-model 429 quota marks keep their
    precedence; this sits above them as an earlier, softer signal.
  • One log line per transition, naming only an 8-character credential fingerprint prefix — never a
    label, email, token, or full id.

Why per-window, and not just the aggregate status

The unsuffixed Anthropic-Ratelimit-Unified-Status is worst-of-windows. Measured against a
real production ledger: 5h warned on 25 responses and 7d_oi on 236, and the aggregate carried a
warning on exactly 261 — the sum, with no overlap. So the aggregate tells you something is warned
but never which, and it cannot clear one window while another is still warning.

That matters in practice, not just in theory. In one observed event the 7d_oi window read
allowed_warning continuously for 3h43m while 5h stayed allowed the entire time. An
implementation keyed on 5h alone, or on the aggregate alone, would have been inert for that whole
window.

A defect this PR fixes in its own first commit

The first commit collected only allowed_warning and treated every other value — including an
explicit allowed — the same as an absent header. Because the only other clear path was the
worst-of-windows aggregate, a window that had recovered could not clear its mark while a different
window was warning. Simulated over a real ledger of 7,788 responses, that produced 12 cases where
a mark was held past an explicit allowed, the longest for 4.8 minutes.

The second commit adds Result.RateLimitClearedWindows and clears each window on its own explicit
allowed. Two properties are deliberately preserved, and both are tested: an absent header still
never clears (absence is not an all-clear), and rejected never clears (it is worse than warned,
not better — the existing 429 path owns that case). They share a single default: no-op arm so they
cannot drift apart.

That commit also updates rateLimitWarningsWouldChange, which is load-bearing rather than cosmetic:
it drives resultNeedsGlobalTransition, which selects the atomic StateMutator.MutateAuthState
path. Without it, a clear-only response reports "nothing changed" and the clear is inert on that
path.

Scoping that precisely, because the obvious reading over-claims it: this is about
cluster consistency, not about the write vanishing on every topology. On a single-node deployment
a clear-only response takes the local path and still applies and persists correctly via
stageResultPersist/scheduleResultPersist. The failure this guards against is the multi-node case,
where the write is routed through the cluster mutator and a false "no change" lets it be dropped or
clobbered.

Concurrency note

authRateLimitWarned iterates the fixed three-window array with a keyed map lookup instead of
range-ing auth.RateLimitWarnings. Selection runs unlocked while MarkResult mutates that map
under a lock, and range is the shape that triggers Go's fatal
concurrent map iteration and map write. The keyed lookup matches the pre-existing unsynchronized
keyed read of auth.ModelStates in isAuthBlockedForModel, so this introduces no new crash mode and
changes no locking. To be clear about what this is not: it de-amplifies an existing unsynchronized
read; it does not fix the underlying lack of synchronization, which is pre-existing and unchanged.

Tests

17 tests in internal/cliproxy/auth/rate_limit_warning_test.go, covering: warning sets the mark;
selector prefers non-warned; falls back to the warned credential when every candidate is warned;
sole-warned-credential never starves; clears on allowed; clears on Reset expiry; per-window clear
while another window warns; rejected does not clear; absent header does not clear; disabled never
selected; the anti-inert guard; 7d_oi header canonicalization; and clone non-aliasing.

7d_oi deserves a note: Go's textproto.CanonicalMIMEHeaderKey does not treat _ as a word
separator, so that key does not canonicalize the way the hyphenated ones do. Header lookups here
are therefore explicitly case-insensitive rather than relying on canonical form.

To be straight about that helper rather than let a reviewer discover it: getHeaderCaseInsensitive's
linear-scan fallback is belt-and-suspenders, not load-bearing. Canonicalization is deterministic
for these keys, so headers.Get alone would suffice for any realistic net/http header set. The
fallback is kept to match this codebase's existing defensive posture for header reads, and it is
exercised directly by TestGetHeaderCaseInsensitiveFallsBackToLinearScan rather than left untested.
Happy to drop it if you would rather not carry the extra path.

gofmt -l clean; go build ./... clean; go vet ./... reports only the pre-existing
internal/cluster/refresh.go:172: unreachable code; go test -count=1 ./... green across all 31
packages. Each new test was observed passing individually rather than inferred from a package-level
ok.

🔴 Warning-avoidance outranks the operator-set PRIORITY field

Stating this explicitly because it is a real behaviour change and it is not obvious from the diff.
collectAvailableByPriority splits candidates into a non-warned bucket and a warned bucket, each
keyed by priority, and getAvailableAuths tries the non-warned bucket first regardless of
priority level
. So a PRIORITY 1 credential with no warning will be preferred over a
PRIORITY 10 credential that is warned. Warning-avoidance is a stronger signal than the operator's
own ordering, and only falls back to priority ordering within each bucket.

That is intended — the whole point is to move off a credential before it errors — but an operator
who has deliberately ranked their credentials should know that a warning overrides that ranking
until the warning clears.

🔴 This PR contains #115

It is branched on top of fix/claude-quota-reset-scope, so it carries that PR's 4 commits as well as
its own 2. #115 is where the unified rate-limit header allowlist and the per-credential reset scoping
live, and this work is not separable from them. If #115 merges first, this reduces to the last two
commits. Reviewing them together is fine; reviewing this one instead of #115 is not.

🤖 Generated with Claude Code

Chanse Arrington and others added 7 commits September 9, 2026 06:20
Home discarded Claude credential-scoped 429 reset info and re-armed a
model-scoped 1s->30m exponential backoff ladder instead, because
parseUsageRetryHints has no "claude" case (falls through to its
default: return nil, nil) and never received response headers at all
-- Anthropic reports its rate-limit reset in the
Anthropic-Ratelimit-Unified-* response headers, not the body.

The node already sends those headers on the same payload Home parses
(CPA's requestDetail.ResponseHeaders, internal/redisqueue/plugin.go);
Home's internal/home/usage_result.go simply never read that field.
That is the seam this change plumbs through.

Four parts, all inside internal/cliproxy/auth/* and
internal/home/usage_result.go (+ tests) so the same patch applies to
both the upstream dev branch and the released v1.0.72 the fleet
currently deploys -- result.go and usage_result.go are byte-identical
between the two; selector.go is not (dev rewrote it), so this change
never touches selector.go:

1. usage_result.go: read the optional "response_headers" field off a
   usage payload into an http.Header and pass it through
   NewUsageResultWithHeaders (a new sibling of NewUsageResult, which
   keeps its old 5-arg signature and existing callers unchanged).
   response_headers is JSON-array-wrapped even for a single value
   (Go's shape for map[string][]string) -- a naive .String() read on
   the array would silently return the raw array text instead of the
   value, so this unwraps explicitly.

2. A new claude_ratelimit.go mirrors CPA's own
   internal/runtime/executor/helps/claude_ratelimit.go
   (ClaudeHeadersIndicateUnifiedRateLimitRejection +
   ParseClaudeRateLimitReset), reimplemented Home-side rather than
   bumping the CPA SDK pin. Credential scope = an explicit rejection
   on the shared 5h or 7d window; a Fable-only 7d_oi rejection stays
   model-scoped when both shared windows are explicitly allowed.
   Deliberately omits CPA's crypto/rand 1-30s fuzz grace period, to
   keep Home's own tests deterministic (no wall-clock jitter).

3. quotaCooldownAfterFailure's gate now checks whether a hint was
   actually produced (Result.ResetAt/RetryAfter != nil) instead of a
   hardcoded antigravity/codex provider allowlist.
   parseUsageRetryHints already returns (nil, nil, false) for any
   provider without an explicit case, so this cannot change
   antigravity/codex behaviour -- proven by parity tests, not just
   asserted -- and stops the allowlist and the provider switch from
   drifting apart.

4. Credential scope: on a Claude 429 with CredentialScope, fan the
   block out across every entry in auth.ModelStates rather than
   writing auth.Unavailable/auth.Quota/auth.NextRetryAfter directly.
   A direct write would be silently overwritten:
   updateAggregatedAvailability ends with three unconditional writes
   deriving those exact fields from auth.ModelStates, and it runs
   right after the 429 branch on every failure path. The fan-out
   works WITH that aggregation instead -- once every ModelState is
   unavailable, the aggregation's own allUnavailable computation sets
   auth.Unavailable/auth.NextRetryAfter correctly, and the existing
   per-model gate in isAuthBlockedForModel (selector.go) blocks every
   model with no selector.go change. Mirrors CPA's own
   sdk/cliproxy/auth/conductor_cooldown.go fan-out. Respects
   disableCooling identically to the existing 429 branch (the fan-out
   lives inside the same cooling-enabled else block).

Known limitation, disclosed rather than hidden: the fan-out only
covers models already present in auth.ModelStates. A Claude model
that has never been dispatched under this credential has no
ModelState to mark and falls through uncovered -- narrower than
today's bug (that model must have been dispatched before to have a
state at all), but real. Addressing it would need an auth-level
selector.go check, which the byte-identical-patch constraint above
rules out for this fix.

Tests: internal/cliproxy/auth/claude_ratelimit_test.go (header
parsing unit tests, including the array-unwrap and case-insensitive
lookup traps), internal/cliproxy/auth/claude_quota_scope_test.go
(gate parity, fan-out, disableCooling, the charter-mandated 4h regression
test), internal/home/claude_usage_result_test.go (end-to-end via
RecordUsagePayload with the real wire-shape JSON, including the
response_headers array-wrapping and canonical-casing traps).

go test ./internal/cliproxy/auth/... ./internal/home/...: 282 passed,
0 failed (full existing suites plus the above, all green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The doc comment above TestQuotaCooldownAfterFailureClaudeResetHintDoesNotReArmTheOneSecondLadder
named a different function, TestRecordUsagePayloadClaudeResetHintDoesNotReArmTheOneSecondLadder.
That name belongs to the sibling test in internal/home/claude_usage_result_test.go, where it is
correct; it was copy-pasted here along with the comment body. No test logic changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sanitizeUsageQuotaHeaders (internal/cluster/quota_ingestion.go) unconditionally
deleted the 'response_headers' field for every provider before
RecordUsagePayload ever saw it. It only ever re-attached a filtered
subset under 'quota_headers' when provider == "codex", so the Claude
quota-reset fix already on this branch (internal/cliproxy/auth/
claude_ratelimit.go, consumed via internal/home/usage_result.go's
parseResponseHeaders) had nothing to read in production: the real
ingest path (internal/respserver/push/usage.go's handleUsage) always
sanitizes before calling RecordUsagePayload. Measured live: 5,758
usage rows, zero with response_headers.

This mirrors the existing codex allowlist mechanism rather than
weakening the strip -- both sjson.Delete calls in
sanitizeUsageQuotaHeaders stay unconditional; a new
isClaudeRateLimitHeaderKey allowlist (the unified status header, the
5h/7d/7d_oi status+reset pairs, and Retry-After -- read off
claude_ratelimit.go, not guessed) lets a Claude payload's collect
pass re-attach the same way codex's already does. A non-allowlisted
header (Authorization, an invented X-Something-Secret) still never
survives.

usage_result.go's parseResponseHeaders now reads both wire shapes:
'response_headers' (JSON-array values, the shape that almost never
survives sanitizing) and the new flat map[string]string
'quota_headers' (what actually does survive), merging with
response_headers taking precedence on a collision.

quotaSnapshotWriteFromUsagePayload's own provider != "codex" gate
(quota_ingestion.go:238) is untouched, so a Claude quota_headers
entry still cannot reach the codex-only quota-snapshot write path --
proved by test, not assumed (TestSanitizeUsageQuotaHeadersCodexBehaviorUnchanged).

Mutation-tested: reverting the collect closure to codex-only makes
the new end-to-end, sanitizer-level, and negative tests fail; restoring
it makes them pass again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two comment-only nits from review, zero behaviour change (gofmt-verified,
tests unchanged):

- claude_ratelimit.go: parseClaudeRateLimitResetAt's own comment claimed
  Home avoids wall-clock nondeterminism generally; in fact only this
  function is deterministic (it takes 'now' as a parameter) -- its
  caller, parseUsageRetryHints's claude case in result.go, still calls
  time.Now().UTC() directly at the call site like every other provider's
  429 path. Reworded to say that precisely instead of overclaiming.

- result.go: blockSiblingModelStatesUntil writes Scope: quotaScopeModel
  onto every sibling ModelState even though the whole fan-out only runs
  for a credential-scoped rejection. Added a comment explaining why that
  is deliberate: aggregateModelQuota (called right after, via
  updateAggregatedAvailability) unconditionally hardcodes
  Scope: quotaScopeModel on the derived auth-level Quota regardless of
  what each ModelState carries, so a future 'fix' to quotaScopeCredential
  here would be silently clobbered on the next aggregation pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anthropic sends Anthropic-Ratelimit-Unified-<window>-Status:
allowed_warning on a successful reply roughly 20 minutes before it starts
rejecting requests on that window. Home now records that as a
RateLimitWarning per window on the credential, de-prefers a warned
credential in the selector (never-starve: it is still served when no
better candidate exists), and clears the mark on an affirmative
unsuffixed Unified-Status: allowed or once the window's Reset epoch
passes.

- types.go: RateLimitWarning type + Auth.RateLimitWarnings field,
  deep-copied in Clone().
- result.go: NewUsageResultWithHeaders parses the new headers via
  parseClaudeRateLimitWarnings (claude-only; the unsuffixed header is a
  safe sweeping all-clear per 542-row measurement, but per-window header
  absence is never read as clearing that window); applyResultTransition
  applies set/clear/expire via applyRateLimitWarningTransition;
  resultNeedsGlobalTransition additionally fires on
  rateLimitWarningsWouldChange so a brand-new warning is never silently
  dropped by the StateMutator gate; availabilityFingerprintValue gains a
  comparable warningsDigest scalar (FNV-1a over sorted windows + ResetAt,
  excluding ObservedAt) so the fingerprint actually changes when a
  warning is applied.
- selector.go: collectAvailableByPriority now buckets warned-but-available
  candidates separately; getAvailableAuths prefers the clean bucket and
  only falls back to the warned bucket when it's empty, so a warned
  credential is de-preferred but never starved out entirely.
- rate_limit_warning_test.go: 13 new tests covering header parsing incl.
  the 7d_oi canonicalization round-trip, selector preference/never-starve/
  all-warned behavior, Disabled exclusion, all-clear and expiry sweeping,
  both anti-inert regressions, provider gating, and Clone() non-aliasing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… own allowed status

parseClaudeRateLimitWarnings dropped a window whose header read "allowed",
making it indistinguishable from a window whose header was absent. The
all-clear only fires when the unsuffixed status is affirmatively "allowed",
which is the worst-of-windows value -- so a 7d_oi window that recovered
while 5h newly warned on the same response never got its mark cleared, and
Home kept de-preferring the credential on a stale window for up to days.

- result.go: Result gains RateLimitClearedWindows []string, populated by
  parseClaudeRateLimitWarnings only for a window whose status is EXACTLY
  "allowed" (case/whitespace-normalized, same as the existing check).
  applyRateLimitWarningTransition deletes each cleared window individually,
  alongside its existing set/expire loops, logging one line per clear at
  the same authFingerprintPrefix granularity as its neighbors.
  rateLimitWarningsWouldChange now also returns true when a cleared window
  currently carries a live mark, so a clear-only response (no new warning,
  nothing expired) is not silently discarded by MutateAuthState's
  unchanged-fingerprint guard -- otherwise the fix above would be inert.
  An absent header still never clears (unchanged), and "rejected" never
  clears (it is worse than warned, not better; PR router-for-me#115's 429 path owns it).
- selector.go: authRateLimitWarned now iterates the fixed
  claudeRateLimitWarningWindows array with a keyed lookup instead of
  ranging auth.RateLimitWarnings. Selection runs unlocked while MarkResult
  mutates that same map under a lock; ranging is the shape that triggers
  Go's fatal "concurrent map iteration and map write". A keyed lookup is
  the same shape as the pre-existing unsynchronized keyed read of
  auth.ModelStates in isAuthBlockedForModel, so this de-amplifies the race
  without introducing a new crash mode or touching any locking.
- rate_limit_warning_test.go: 5 new tests (17 total, from 12) -- the
  headline per-window-clears-while-a-different-window-warns regression,
  rejected-never-clears, absent-never-clears, the anti-inert guard on
  rateLimitWarningsWouldChange, and authRateLimitWarned's keyed-lookup
  rewrite behaving identically across warned/not-warned/expired-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…as a separate issue

No such issue exists in this (or any) tracker. The prior wording
referenced a private internal work-item that upstream cannot see and
must never appear in code submitted here. Replace it with an honest
statement: the underlying lack of synchronization is pre-existing in
this package and is deliberately left out of scope for this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant