fix(auth): honour Claude unified rate-limit resets and scope credential 429s - #115
Open
chansearrington wants to merge 4 commits into
Open
fix(auth): honour Claude unified rate-limit resets and scope credential 429s#115chansearrington wants to merge 4 commits into
chansearrington wants to merge 4 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>
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.
Problem
When a Claude credential hits its rate limit, Home discards the reset timing Anthropic sends and
substitutes its own exponential backoff, then re-dispatches the exhausted credential minutes later.
This is the same defect as #89 ("Home ignores Codex usage_limit_reached reset timing during
scheduling"), one provider over. #89 was fixed by adding a
case "codex":toparseUsageRetryHintsand addingcodexto an allowlist. That approach cannot work for Claude,for a reason worth stating up front because it explains why this PR touches an extra file:
There are three separate causes, all in Home. Line numbers are at
v1.0.72.1. The reset hint is discarded for every provider except two.
quotaCooldownAfterFailure(
internal/cliproxy/auth/result.go:973) computes an exponential deadline and then returns earlyunless the provider is one of two hardcoded names:
So every Claude 429 falls back to the
quotaBackoffBase→quotaBackoffMaxladder (1s → 30m),regardless of the 5h/7d window Anthropic actually applied.
2. A credential-wide rejection is recorded as if it affected only one model. In the 429 branch
(
result.go:252-273) the scope is hardcoded toquotaScopeModel. Anthropic's unified rate limit isscoped to the credential. Home already understands a
"credential"scope elsewhere(
cooldown.go:559), but nothing on the 429 path ever produces one — so sibling models on the sameexhausted credential stay dispatchable.
3. Home deletes the headers carrying the answer, before its own scheduler runs.
handleUsage(internal/respserver/push/usage.go) sanitizes the payload and reassigns thevariable before passing it on:
and inside that sanitizer (
internal/cluster/quota_ingestion.go):The
provider != "codex"guard gates extraction, not the delete. Codex's headers survive onlybecause they are allowlisted into
filteredfirst and re-attached underquota_headers. Claude'sare destroyed.
This is the only such path: across
internal/clusterandinternal/respserverthere are exactlythree
sjson.Deletecalls, the two above and one insidesanitizeUsageUpstreamRequestIDsthattouches only request-ID fields.
Production evidence
Measured on a live Home
v1.0.72instance, from its own database (non-secret columns only).A Claude credential rejected at
11:15:56Zwas givennext_retry_after = 11:18:05Z— 2 minutes9 seconds later, against Anthropic quota windows of 5h and 7d. That is the exponential ladder, not
the credential's reset.
Eight consecutive 429s against an already-exhausted quota inside 3.5 minutes:
51 Claude 429s over ~33 hours, later ones settling into near-exact 1-hour spacing consistent with
the ladder saturating at its 30m cap and being re-armed on each failure.
Confirming cause 3 independently: of 5,758 usage rows in that database, zero contain
response_headersand zero containanthropic-ratelimit— while 5,758/5,758 have non-emptypayloads (positive control), and 51 genuine Claude 429s are present.
This is happening in production
Observed on a running
v1.0.72deployment (a small self-hosted cluster), 24h window — 208 log linesmatching
429|rate limit|cooldown|quota. Redacted sample:Subsequent 429s for the same credential and model:
04:21:51 → 04:21:59 → 04:22:56 → 04:23:29 → 04:24:04 → 04:24:28 → 04:25:16— gaps of 8s, 57s, 33s, 35s, 24s, 48s. It keeps re-attempting arate-limited credential within seconds rather than standing down until the advertised reset.
Those two adjacent lines are the whole bug. The first is emitted by the node, which parses
Anthropic's reset headers correctly and reports them upward.
sanitizeUsageQuotaHeadersin Homethen deletes those headers before the scheduler ever sees them. The information the scheduler needs
is already being produced correctly, one hop away, and discarded on arrival.
(To be precise about what this log does and does not show: request arrival is driven by real
traffic, so these gaps corroborate the defect rather than measure the backoff ladder directly. The
decisive evidence that the ladder re-arms at ~1s is the mutation test under Testing.)
Changes
1. Gate on whether a reset hint exists, rather than on a provider allowlist (
result.go):Deliberately provider-agnostic rather than "add
claudeto the allowlist". The allowlist and theparseUsageRetryHintsprovider switch are two lists that must agree and nothing enforced that theydid — which is how #89 happened for Codex and how this happened for Claude.
parseUsageRetryHintsstill returns
(nil, nil)from itsdefaultbranch, so any provider that produces no hint reachesexactly the same early return as before:
antigravityandcodexbehaviour is unchanged. A paritytest keeps the two lists from drifting apart again.
2. Parse Anthropic's unified rate-limit headers (new
claude_ratelimit.go). ReadsAnthropic-Ratelimit-Unified-Statusand the-5h-/-7d-/-7d_oi-status and reset variants plusRetry-After, and classifies a rejection as credential- or model-scoped. Case-insensitive lookup;latest deadline wins across windows.
3. Fan a credential-scoped rejection across the credential's model states (
result.go). When a429 is classified credential-scoped,
blockSiblingModelStatesUntilmarks every knownModelStateon that credential unavailable until the reset. It writes through
ModelStatesrather than settingaggregate fields directly, so the existing
updateAggregatedAvailabilityderivation — alreadycalled at the end of both the success and failure paths — produces
Unavailable/NextRetryAfter/Quotaexactly as it always has.disableCoolingsuppresses the fan-out.4. Let Claude's rate-limit headers survive sanitizing (
quota_ingestion.go). Thecollectclosure becomes provider-aware: codex keeps
isCodexQuotaHeaderKeyunchanged, Claude gains amatching
isClaudeRateLimitHeaderKeyallowlist, both under the existingquotaHeaderValueMaxLengthcap. Both deletes stay unconditional — the wholesale strip is thefunction's security posture and is not weakened; only an explicit, named allowlist survives, and it
survives by the same
quota_headersmechanism codex already uses.5. Read the surviving headers (
internal/home/usage_result.go).RecordUsagePayloadpreviouslyparsed only the response body. It now reads
response_headers(array-valued,map[string][]stringshape) and
quota_headers(flatmap[string]stringshape), merging them withresponse_headerstaking precedence, and returns
nilwhen neither is usable so callers fall back to body-onlyparsing exactly as before.
Scope
Four production files, one of them new; the other six are tests. The one change to an existing
test file (
usage_result_test.go, +13) adds a single exported test-only helper(
NewUsageResultTestRuntime) so the end-to-end test can live inpackage home_test;internal/clusterimportsinternal/homein production code, so an in-package test importingclusterwould be a genuine import cycle. It is in a_test.gofile and is not compiled intothe production binary — happy to restructure if you would rather not have the exported helper.
Deliberately unchanged:
internal/cliproxy/auth/selector.go(see Known limitations); no SDK pinbump; no route, request/response field, or config key added or changed;
AGENTS.mduntouched.Codex cannot be affected by change 4. The only downstream consumer of
quota_headersisquotaSnapshotWriteFromUsagePayload(quota_ingestion.go:235), whose first statement isif provider != "codex" || credentialID == "" { return QuotaSnapshotWrite{}, false }— a Claudepayload can never reach the codex quota-snapshot writer. There is a test asserting this rather than
assuming it.
Testing
Go 1.26.0, clean tree.
gofmt -lgo vet ./...internal/cluster/refresh.go:172:2: unreachable code— confirmed present on the base commit, unrelated to this changego test -count=1(4 affected packages)ok—internal/cluster,internal/cluster/management,internal/home,internal/cliproxy/authCGO_ENABLED=1 go build ./cmd/homeThe four package suites were additionally re-run independently, offline (
GOPROXY=off), from aclean tree — all four
ok.Also verified against the
v1.0.72tag (a4fbe87), not onlydev— and re-verified after thefinal commit rather than only the first two. All three commits cherry-pick onto the tag with no
conflict;
go build ./...andCGO_ENABLED=1 go build -o test-output ./cmd/homeboth succeedthere, and the same four package suites pass. This matters because
v1.0.72is the release we run,so the fix is confirmed on the code actually in production, not only on
dev.The tests are not vacuous — each was confirmed to fail when the mechanism it covers is removed:
Each mechanism was individually reverted and the covering test re-run, to prove the test
fails for the right reason rather than passing by agreement with itself:
claudearm of the sanitizer's providerswitch(headers stripped again, i.e. pre-fix behaviour)TestRecordUsagePayloadHonoursClaudeHeadersThroughRealSanitizePathNextRetryAfterre-armed ~1s out instead of the 4h header resetsanitizeUsageQuotaHeadersquota_headers missingAuthorizationabsent?" check alone would have been vacuousThe first row is the one that matters most, and its failure message is the exact defect this PR
exists to fix:
The regression test asserts the deadline equals a reset 4 hours out and explicitly asserts the
delay is not
time.Second— it asserts the output is not what the unfixed code would have produced,not merely that the new code agrees with itself.
One test is worth calling out, because its absence is why causes 1–2 could be fixed and still do
nothing: an end-to-end case that pushes a Claude 429 payload through
cluster.SanitizeUsagePayloadSecretsfirst and only then intoRecordUsagePayload— the sameorder
handleUsageuses in production. Every pre-existing test callsRecordUsagePayloaddirectly,which is exactly the code path production does not take.
Heads-up on CI coverage: this repo's PR CI (
pr-test-build.yml) runsgo testonly againstinternal/cluster(line 42) and otherwisego builds./cmd/home(line 45), which doesn't compile_test.gofiles — so the new suites ininternal/cliproxy/authandinternal/homewon't executeunder this PR's CI, even though they were run locally and are green (see table above). Flagging this
so a green check here isn't mistaken for those two packages' tests having run; you may want to widen
the CI test path to cover them, but I've left the workflow file itself untouched in this PR.
Compatibility
antigravityorcodex— verified by mutation-testing the gate, bythe unchanged pre-existing suites, and structurally via the
defaultbranch returning no hint.documentation impact.
response_headers/quota_headersremain optional — a payload without them behaves exactlyas before.
of non-secret Anthropic rate-limit headers survives, mirroring the existing codex allowlist. There
is a negative test asserting a non-allowlisted header does not survive.
Known limitations (disclosed, not fixed here)
A model with no
ModelStateentry is not blocked by a credential-wide window.isAuthBlockedForModel(selector.go:340-386) falls through toreturn false, blockReasonNone, …when the queried model has no entry. Exposure is narrow: a model never dispatched under thatcredential has no state to block, so this bites only when a sibling model is tried for the first
time during an active credential-wide window — that one request goes through, gets its own 429,
and now correctly picks up its own reset hint. Closing it means changing
selector.go, whichdiffers between
v1.0.72anddev; staying out of that file is what lets one change serve both.This is a deliberate scope boundary rather than an oversight:
result.goandusage_result.gostay byte-identical between the
v1.0.72tag anddev, which is what lets this fix applyunmodified to both, while
selector.godoes not, so reaching into it here would stop being aminimal, upstream-shaped diff. The gap is self-healing in one request — the sibling model's own
429 populates the
ModelStatethe credential-wide block was missing, so it is never leftuncovered a second time.
The Anthropic header names are no longer unverified in the way an earlier note here said —
confirmed by identity with a CPA parser that has itself been observed succeeding against a live
Anthropic 429 — but the header value formats are still not independently confirmed here. CPA
(the node binary; the version fetched for this comparison is tag
v7.2.154, what the fleet's ownnodes actually run) defines its Claude rate-limit header names in
internal/runtime/executor/helps/claude_ratelimit.go. Fetched from that exact tag, it containsexactly 9 header literals. This PR's
claudeRateLimitHeaderKeysallowlist contains exactly thesame 9, byte-identical as sorted, case-normalised sets (
diffexit 0), including the awkward7d_oispelling — a negative control confirmed the comparison actually discriminates rather thantrivially matching everything. That CPA parser is the same one behind the
[claude_ratelimit.go:175] parsed Anthropic rate limit reset headersnode log line already quotedabove under Production evidence — i.e. it has itself been observed successfully parsing a real
Anthropic 429, not just read as source. Separately, Go's
http.CanonicalHeaderKeywas run againstall nine allowlist entries and canonicalizes every one to exactly the key the lookup expects —
including the underscore in
7d_oi, where the non-letter7blocks Go's hyphen-triggeredcapitalization and the
_is passed through untouched. The mixed-case spelling Anthropic actuallyemits,
Anthropic-RateLimit-Unified-7d_oi-Status(capitalL), canonicalizes onto the same key,so the allowlist is casing-agnostic by construction —
collect()canonicalizes before everylookup. (A positive control,
FOO-bar-BAZ→Foo-Bar-Baz, confirms the function rewrites itsinput rather than returning it, so the identity-looking matches are real.)
What remains genuinely unverified, narrower than before: the header value formats on the
wire. We have not independently confirmed those here — they remain covered only by that same
parser's own tolerance for unix-seconds / RFC3339 / HTTP-date, not by a response we captured
ourselves. To be precise about the evidence: we did not capture an Anthropic response in the
course of writing this PR; the live-429 confirmation is CPA's own parser succeeding, observed in
the node's own log.
Overlap with fix(auth): fail closed on unbounded credential unavailability #92. That PR also rewrites quota/cooldown/availability logic in
result.goandis still open. This change is scoped far more narrowly, but the two will conflict there and
whichever lands second needs a rebase. Happy to rebase onto fix(auth): fail closed on unbounded credential unavailability #92 if you would rather take it
first.
A Claude reset-driven cooldown still consumes one backoff level. On the first 429 with a
fresh
QuotaState,nextQuotaCooldownruns before theResetAtoverride applies, so a laterunrelated hint-less failure resumes from level 1 rather than 0 (2s instead of 1s). Bounded by
quotaBackoffMax, cleared by any intervening success. Left as-is deliberately —ResetAtoverrides the deadline, not the level.
Related: #89 (the Codex instance of this defect — left open, not closed by this PR, since this
change does not address the Codex case it describes).