Pre-emptively de-prefer a Claude credential on allowed_warning - #116
Open
chansearrington wants to merge 7 commits into
Open
Pre-emptively de-prefer a Claude credential on allowed_warning#116chansearrington wants to merge 7 commits into
chansearrington wants to merge 7 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pre-emptively de-prefer a Claude credential on
allowed_warningToday 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_warningbefore it starts rejecting. ThisPR reads that signal and starts preferring another credential while the warned one is still serving.
Behaviour
5h,7d,7d_oi): a warning mark is set when a successfulresponse's
Anthropic-Ratelimit-Unified-<window>-Statusis exactlyallowed_warning, carryingthat window's
-Resetepoch when present.allowed, when the unsuffixed aggregatestatus reports
allowed, or when the Reset epoch passes.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.
disabledcredential is never a candidate. The existing per-model 429 quota marks keep theirprecedence; this sits above them as an earlier, softer signal.
label, email, token, or full id.
Why per-window, and not just the aggregate status
The unsuffixed
Anthropic-Ratelimit-Unified-Statusis worst-of-windows. Measured against areal production ledger:
5hwarned on 25 responses and7d_oion 236, and the aggregate carried awarning 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_oiwindow readallowed_warningcontinuously for 3h43m while5hstayedallowedthe entire time. Animplementation keyed on
5halone, or on the aggregate alone, would have been inert for that wholewindow.
A defect this PR fixes in its own first commit
The first commit collected only
allowed_warningand treated every other value — including anexplicit
allowed— the same as an absent header. Because the only other clear path was theworst-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.RateLimitClearedWindowsand clears each window on its own explicitallowed. Two properties are deliberately preserved, and both are tested: an absent header stillnever clears (absence is not an all-clear), and
rejectednever clears (it is worse than warned,not better — the existing 429 path owns that case). They share a single
default:no-op arm so theycannot drift apart.
That commit also updates
rateLimitWarningsWouldChange, which is load-bearing rather than cosmetic:it drives
resultNeedsGlobalTransition, which selects the atomicStateMutator.MutateAuthStatepath. 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
authRateLimitWarnediterates the fixed three-window array with a keyed map lookup instead ofrange-ingauth.RateLimitWarnings. Selection runs unlocked whileMarkResultmutates that mapunder a lock, and
rangeis the shape that triggers Go's fatalconcurrent map iteration and map write. The keyed lookup matches the pre-existing unsynchronizedkeyed read of
auth.ModelStatesinisAuthBlockedForModel, so this introduces no new crash mode andchanges 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 clearwhile another window warns;
rejecteddoes not clear; absent header does not clear; disabled neverselected; the anti-inert guard;
7d_oiheader canonicalization; and clone non-aliasing.7d_oideserves a note: Go'stextproto.CanonicalMIMEHeaderKeydoes not treat_as a wordseparator, 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'slinear-scan fallback is belt-and-suspenders, not load-bearing. Canonicalization is deterministic
for these keys, so
headers.Getalone would suffice for any realisticnet/httpheader set. Thefallback is kept to match this codebase's existing defensive posture for header reads, and it is
exercised directly by
TestGetHeaderCaseInsensitiveFallsBackToLinearScanrather than left untested.Happy to drop it if you would rather not carry the extra path.
gofmt -lclean;go build ./...clean;go vet ./...reports only the pre-existinginternal/cluster/refresh.go:172: unreachable code;go test -count=1 ./...green across all 31packages. Each new test was observed passing individually rather than inferred from a package-level
ok.🔴 Warning-avoidance outranks the operator-set
PRIORITYfieldStating this explicitly because it is a real behaviour change and it is not obvious from the diff.
collectAvailableByPrioritysplits candidates into a non-warned bucket and a warned bucket, eachkeyed by priority, and
getAvailableAuthstries the non-warned bucket first regardless ofpriority level. So a
PRIORITY 1credential with no warning will be preferred over aPRIORITY 10credential that is warned. Warning-avoidance is a stronger signal than the operator'sown 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 asits 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