fix(auth): floor quota cooldown at the escalating ladder - #198
Conversation
Providers answering 429 for an exhausted daily quota can attach a RetryInfo hint far shorter than the real recovery window. Gemini and Antigravity were observed returning 479417207ns while the key stayed dead for the rest of the day. Both quota paths took that hint verbatim, so an exhausted credential returned to the pool half a second later and BackoffLevel never advanced past its current step: every retry recomputed the same level and immediately overwrote the deadline with the sub-second hint. Compute the escalating ladder first and let a provider hint only push the deadline further out, never pull it in. A genuine long hint still wins; a sub-second one can no longer undercut the ladder. Covered by TestMarkResultSubSecondQuotaHintStillEscalates and TestApplyAuthFailureStateSubSecondQuotaHintStillEscalates in sdk/cliproxy/auth/cooldown_backoff_test.go.
Do not apply the escalating quota ladder floor when a 429 response explicitly specifies a zero or non-positive RetryAfter duration (e.g. transient websocket connection limit errors). Escalating quota cooldown remains gated to positive retry hints and default quota exhaustion.
decideAntigravity429 classifies a RATE_LIMIT_EXCEEDED 429 whose retry hint is shorter than three seconds as an instant retry on the same credential rather than an exhausted quota. The unconditional ladder floor still replaced that hint with a quota cooldown step, parking a still-usable credential for up to the full ladder window. Carry the executor classification through statusErr and Result so the ladder floor only applies to 429s that were not decisively classified as a short-lived rate limit. Exhausted quota and unclassified bodies keep the floor and keep escalating exactly as before.
The transient rate-limit flag only reached the conductor from the non-stream execution path. Streaming failures built their results from retryAfterFromError alone, and the Antigravity token-count path built statusErr by hand, so both still floored a provider-classified short-lived 429 at the quota ladder and parked a usable credential. Set TransientRateLimit next to every RetryAfter assignment in the streaming pool, and build the token-count errors through newAntigravityStatusErr so they inherit the same classification.
The Antigravity executor raises a synthetic 429 while an auth sits in a short cooldown. That cooldown is a local, self-imposed pause of at most a few minutes, but the error carried only a positive retryAfter hint and no classification, so isTransientRateLimitError() returned false and MarkResult()/applyAuthFailureState() read it as an exhausted upstream quota. BackoffLevel then escalated toward the 30 minute ceiling and parked an account that was never throttled upstream. Set transientRateLimit on all three cooldown short-circuits (Execute, executeClaudeNonStream, ExecuteStream) so the conductor rotates to the next auth instead of escalating backoff. Covered by TestAntigravityShortCooldownErrorIsTransient, which asserts the classification on all three entry points.
|
Mirrored the upstream review follow-up from router-for-me/CLIProxyAPI#5130 as d9edde2: the synthetic short-cooldown 429 raised by the Antigravity executor is now marked Test: |
classifyClaudeUpstreamError built ordinary (non-unified) Claude 429s as claudeRateLimitError wrapping a statusErr with no transientRateLimit flag, so isTransientRateLimitError() returned false and MarkResult() treated an ordinary model-level throttle as exhausted quota, escalating BackoffLevel toward the 30 minute ceiling and parking a credential that was only briefly throttled. Mark the ordinary path transient. Unified 5h/7d rejections keep the quota ladder untouched. Covered by TestClassifyClaudeUpstreamError_OrdinaryRateLimitIsTransient and TestClassifyClaudeUpstreamError_UnifiedRejectionNotTransient.
|
Mirrored the second upstream review follow-up from router-for-me/CLIProxyAPI#5130 as 193a4ae: ordinary (non-unified) Claude 429s from Tests: |
Both 429 cooldown branches (MarkResult's per-model state and applyAuthFailureState's credential state) only kept a short-lived rate limit out of the quota ladder when a parseable retryAfter hint was present. A transient 429 with no hint fell through to quotaCooldownAfterFailure and advanced BackoffLevel toward the 30 minute ceiling, parking a credential that was only briefly throttled. Transient 429s now bypass the ladder regardless of hint presence: the provider-supplied retryAfter is kept verbatim when present, and without a hint the cooldown falls back to nextTransientErrorRetryAfter (the standard ~60s transient-error cooldown). Covered by TestManager_MarkResult_Transient429WithoutHintBypassesQuotaLadder, which asserts both the per-model and credential quota ladders stay at level 0 and both NextRetryAfter values land at the transient cooldown.
|
Mirrored the third upstream review follow-up from router-for-me/CLIProxyAPI#5130 as 48444c8: transient 429s without a parseable hint now bypass the quota ladder in both copies of the cooldown logic ( Test: |
The transient-429 fallback (nextTransientErrorRetryAfter) returns a zero time when transient cooldowns are disabled (transientErrorCooldownSeconds < 0). Both 429 cooldown branches still stored that zero alongside Unavailable=true and Quota.Exceeded=true with an empty NextRecoverAt, and availabilityBlock read the zero-time quota block as an indefinite park. When the transient fallback yields a zero time the 429 handling now leaves the model and the credential available: no quota mark, no suspension, no retry time. A pre-existing quota block is preserved in both paths. Covered by TestManager_MarkResult_Transient429WithoutHintRespectsDisabledCooldown.
|
Mirrored the fourth upstream review follow-up from router-for-me/CLIProxyAPI#5130 as 7d7fe41: when transient cooldowns are disabled, the transient-429 zero-time fallback no longer gets stored as an indefinite quota block — both 429 branches skip the marking and preserve any pre-existing quota state. Test: |
…ient cooldown The transient-cooldown-off skip in applyAuthFailureState restored the status message and quota fields but left auth.Unavailable=true (set at the top of the function) and auth.NextRetryAfter untouched, so a credential hit by a transient 429 without a hint stayed indefinitely blocked anyway. Capture the prior availability fields and restore them in the skip. Covered by TestManager_MarkResult_Transient429WithoutHintRespectsDisabledCooldownAuthLevel, which drives an auth-level Result (empty Model) through applyAuthFailureState.
…o as transient When Google returns RESOURCE_EXHAUSTED with an ErrorInfo reason of RATE_LIMIT_EXCEEDED but omits the RetryInfo detail, the decision table downgrades to SoftRetry and the resulting error reported TransientRateLimit() == false. The conductor then read a plain per-minute throttle as exhausted quota and escalated BackoffLevel toward the 30 minute ceiling instead of rotating. newAntigravityStatusErr now also marks the soft rate limit transient, but only when the classification came from the ErrorInfo reason: the bare "too many requests" message heuristic stays on the quota ladder. Covered by a new case in TestNewAntigravityStatusErrMarksTransientRateLimit.
|
Mirrored two more upstream review follow-ups from router-for-me/CLIProxyAPI#5130:
|
|
The I don't have admin rights to rerun the job — a maintainer re-run of the failed job should turn it green. Happy to make the probe less timing-sensitive in a follow-up if it keeps flaking. |
…ng test TestAntigravityConcurrentRequestsReusePooledConnections failed in CI with 9 distinct connections for 3 waves of 8, even though pooling works: a wave boundary can cost one extra dial when an idle connection is retired at exactly the wrong moment. Allow one stray dial per later wave. The MaxIdleConnsPerHost=2 regression still fails loudly: it would open roughly totalConns - 2*(waves-1) distinct connections (20 here), far above the new allowance of 10. Verified with 10 consecutive local runs on this commit: 10/10 pass.
TestExecuteStream_PublishesUsageRecordFromStreamUsage timed out in CI at its 5s cap while waiting for the asynchronously published usage record. The cap only bounds the failure case — a matching record returns immediately — so raising it to 30s costs nothing on success and absorbs scheduler starvation on loaded runners. Verified with 3/3 consecutive local runs on this commit.
Summary
Gemini and Antigravity can answer HTTP 429 for a fully exhausted daily quota with a
RetryInfohint of only ~479ms (observed:479417207ns). Taking that hint verbatim returned the dead credential to the pool ~500ms later AND pinnedBackoffLevelforever because every retry recomputed the ladder and then discarded it in favor of the sub-second hint.This change floors the quota cooldown deadline at the escalating ladder calculation. A provider hint may still push the recovery deadline further out, but can never pull it in below the ladder step — except for the two cases the provider itself marks as non-exhaustion (see the follow-up rounds below).
Code changes
Before
After
Blast radius
quotaCooldownAfterFailureis package-private with exactly two call sites:MarkResultinsdk/cliproxy/auth/conductor_cooldown.goapplyAuthFailureStateinsdk/cliproxy/auth/conductor_cooldown.goBoth call sites are updated identically to respect the ladder floor.
Tests & Verification
Added two unit tests in
sdk/cliproxy/auth/cooldown_backoff_test.go:TestMarkResultSubSecondQuotaHintStillEscalatesTestApplyAuthFailureStateSubSecondQuotaHintStillEscalatesMandatory reverse bite-check failure
Reverting the fix produced the expected test failure:
Passing tests with fix applied
Follow-up review round 1 — zero-delay retries (
165e9326)Review pointed out that an unconditional floor also swallowed
RetryAfter: 0, which upstreamuses to mean "retry immediately on the same credential". The gate now exempts a non-positive
hint (
conductor_cooldown.go:829inMarkResult,:1966inapplyAuthFailureState).Tests:
TestMarkResultZeroRetryAfterDoesNotApplyLadderFloor,TestApplyAuthFailureStateZeroRetryAfterDoesNotApplyLadderFloor.Reverse bite-check — dropping the
*retryAfter <= 0clause from both gates:Follow-up review round 2 — transient rate limits (
c6ec29ef)Review then pointed out that the floor still applied to 429s the executor had already
classified as a short-lived rate limit rather than an exhausted quota, parking a usable
credential for a whole ladder step.
classifyAntigravity429(internal/runtime/executor/antigravity_executor_credits.go:203) alreadydistinguishes
RATE_LIMIT_EXCEEDEDfromQUOTA_EXHAUSTED, but the classification never reachedthe conductor:
newAntigravityStatusErrbuilt a barestatusErr{code, msg, retryAfter}and onlystatus, text and hint were carried across.
statusErrgainstransientRateLimit boolandfunc (e statusErr) TransientRateLimit() bool(
internal/runtime/executor/openai_compat_executor.go:1031). Every other executor leaves itfalse, so their behaviour is unchanged.newAntigravityStatusErrsets it for 429s only:err.transientRateLimit = classifyAntigravity429(body) == antigravity429RateLimited(
antigravity_executor_credits.go:344). Deliberately narrow:QUOTA_EXHAUSTED, soft bodiesand anything unclassified keep the floor and keep escalating.
isTransientRateLimitError(sdk/cliproxy/auth/conductor_cooldown.go:1459) recovers the flagwith
errors.Asover aninterface{ TransientRateLimit() bool }, mirroring howretryAfterFromErroralready recovers the hint. The execution paths setresult.TransientRateLimitnext to the existingresult.RetryAfterassignment.The flag lives on
Result, not onauth.Error, and is threaded as the sixth parameter ofapplyAuthFailureState:sdk/cliproxy/auth/errors_compat_test.goTestErrorLegacyUnkeyedLiteralCompatibilityconstructsErrorwith an unkeyed literal, so anynew field on that struct breaks
go vet. The seven pre-existingapplyAuthFailureStatecallsites in
cooldown_backoff_test.gowere updated mechanically with a trailing, false; noassertion was changed.
Tests:
TestMarkResultTransientRateLimitKeepsProviderHint,TestApplyAuthFailureStateTransientRateLimitKeepsProviderHint(its second half feeds the samesub-second hint through an exhausted-quota error and asserts the floor and
BackoffLevel == 1still apply),
TestIsTransientRateLimitErrorDetectsWrappedProviderClassification,TestNewAntigravityStatusErrMarksTransientRateLimit.Reverse bite-check — reverting both gates to
*retryAfter <= 0:Reverse bite-check — discarding the executor classification:
go build ./...,go vet ./sdk/cliproxy/... ./internal/runtime/executor/...,gofmt -landgo test ./sdk/cliproxy/auth/... ./internal/runtime/executor/...are clean.Follow-up review round 3 — streaming and token-count paths (
8c07697e)Review found the classification was still only wired into the non-stream execution path.
executeStreamWithModelPoolbuilt every failure result fromretryAfterFromErroralone. Allfive results now set the flag next to the hint:
sdk/cliproxy/auth/conductor_stream.go:275,:354,:374,:386,:401. The mid-stream result inwrapStreamResult(:131) is leftalone on purpose — it sets no hint today, so the ladder still applies there and adding one
would be a behavioural change beyond this review.
CountTokenshand-rolledstatusErrtwice. Both now go throughnewAntigravityStatusErr(internal/runtime/executor/antigravity_executor_tokens.go:167and:172), which does the samehelps.ParseRetryDelaywork and additionally appliesclassifyAntigravity429.Tests:
TestExecuteStreamKeepsProviderHintForTransientRateLimit(new filesdk/cliproxy/auth/conductor_stream_classification_test.go, drives a realManager.ExecuteStreamagainst a stub executor whose error reports 429 +
479417207ns+ transient) andTestAntigravityCountTokensClassifiesTransient429(realCountTokensagainst anhttptestserver serving a structured
RATE_LIMIT_EXCEEDED429).Reverse bite-check — dropping the new line from the executor-error stream branch:
Reverse bite-check — restoring the hand-rolled token-count
statusErr:Upstream counterpart: router-for-me/CLIProxyAPI#5130
Review follow-up: short-cooldown 429s are transient
The Antigravity executor raises a synthetic 429 while an auth sits in a short cooldown. That cooldown is a local, self-imposed pause of at most a few minutes, but the error carried only a positive
retryAfterhint and no classification, soisTransientRateLimitError()(sdk/cliproxy/auth/conductor_cooldown.go:1459) returned false andapplyAuthFailureState()(sdk/cliproxy/auth/conductor_cooldown.go:914) treated it as an exhausted upstream quota, escalatingBackoffLeveltoward the 30 minute ceiling.All three cooldown short-circuits now set
transientRateLimit: true—internal/runtime/executor/antigravity_executor_execute.go:36(Execute),:268(executeClaudeNonStream) andinternal/runtime/executor/antigravity_executor_stream.go:35(ExecuteStream) — because the classification is consumed independently on each path (conductor_execution.go:381,conductor_stream.go:275/354/374/386/401,conductor_home_execution.go:181).Test:
TestAntigravityShortCooldownErrorIsTransientininternal/runtime/executor/antigravity_executor_cooldown_transient_test.go.Reverse bite-check — dropping
transientRateLimit: truefrom the three literals:Review follow-up: ordinary Claude 429s are transient
Ordinary (non-unified) Claude 429s reached
claudeRateLimitErrorwrapping astatusErrwith notransientRateLimitflag (internal/runtime/executor/claude_executor_request.go:295-308), soisTransientRateLimitError()returned false andapplyAuthFailureState()treated an ordinary model-level throttle as exhausted quota, escalatingBackoffLeveltoward the 30 minute ceiling. The ordinary path inclassifyClaudeUpstreamErrornow setstransientRateLimit = true; the unified 5h/7d rejection path is untouched and stays on the quota ladder.Tests:
TestClassifyClaudeUpstreamError_OrdinaryRateLimitIsTransientandTestClassifyClaudeUpstreamError_UnifiedRejectionNotTransient(the negative pin for the unified path).Reverse bite-check — dropping
err.transientRateLimit = true:Review follow-up: transient 429s without a hint bypass the quota ladder
The ladder bypass previously required a parseable
retryAfterhint, so a transient 429 with no hint (e.g. an ordinary Claude throttle without reset headers) fell intoquotaCooldownAfterFailureand advancedBackoffLevelanyway. Both copies of the logic —MarkResult's per-model state (sdk/cliproxy/auth/conductor_cooldown.go:829) andapplyAuthFailureState's credential state (:1917) — now bypass the ladder for any transient 429, keeping the hint verbatim when present and falling back tonextTransientErrorRetryAfter(~60s) otherwise.Test:
TestManager_MarkResult_Transient429WithoutHintBypassesQuotaLadder.Reverse bite-check — reverting
conductor_cooldown.go:Review follow-up: transient fallback respects disabled cooldowns
With
transientErrorCooldownSeconds < 0the transient fallback returns a zero time, but both 429 branches still recordedUnavailable=true/Quota.Exceeded=truewith an emptyNextRecoverAt, whichavailabilityBlockreads as an indefinite park. The transient-429 handling inMarkResult(sdk/cliproxy/auth/conductor_cooldown.go:861) andapplyAuthFailureState(:1952) now skips the marking when the transient fallback yields a zero time, preserving any pre-existing quota block.Test:
TestManager_MarkResult_Transient429WithoutHintRespectsDisabledCooldown.Reverse bite-check — reverting
conductor_cooldown.go:Review follow-up: disabled-cooldown skip restores availability fields
The transient-cooldown-off skip restored the status/quota fields but left
auth.Unavailable=true(set at the top ofapplyAuthFailureState) andauth.NextRetryAfteruntouched, so the credential stayed blocked anyway. The prior availability fields are now captured and restored (sdk/cliproxy/auth/conductor_cooldown.go:2027).Test:
TestManager_MarkResult_Transient429WithoutHintRespectsDisabledCooldownAuthLevel(auth-level Result drivesapplyAuthFailureState).Reverse bite-check — reverting
conductor_cooldown.go:Review follow-up: reasoned RATE_LIMIT_EXCEEDED without RetryInfo is transient
RATE_LIMIT_EXCEEDEDwithout aRetryInfodetail downgrades toSoftRetry, and only theRateLimitedcategory was marked transient, so a plain per-minute throttle was read as exhausted quota.newAntigravityStatusErrnow marks the soft rate limit transient when the classification comes from the ErrorInfo reason (antigravity_executor_credits.go:350); the bare "too many requests" message heuristic still stays on the quota ladder.Test: new case in
TestNewAntigravityStatusErrMarksTransientRateLimit.Reverse bite-check — reverting
antigravity_executor_credits.go:Tooling note
The
jbcontextCLI is installed on this machine but its stored session cannot be decrypted (the OS keychain is not accessible), sojbcontext searchcould not run. The equivalent semantic search, review and blast-radius passes were performed with local code-intelligence tooling instead.