fix(credential-pool): honor fuzzy throttle windows, stop cooldown bypass, wait for transient recovery - #82375
Open
someaka wants to merge 1 commit into
Open
Conversation
…ass, wait for transient recovery
Real-world failure class (hyper.charm.land, 3-key pool): a transient 429
'Please try again in a few minutes.' benched the funded key for the full
1-hour default TTL, rotation walked into two genuinely-depleted 402 keys,
the pool hit 'no available entries' and the turn aborted as billing-
exhausted — while the funded key recovered in minutes. Meanwhile runtime
resolution silently fell through to the config singleton key, serving keys
the pool had benched for billing and burning requests on depleted accounts.
Four fixes:
1. Cooldown sizing (credential_pool.py): _extract_retry_delay_seconds now
parses fuzzy retry guidance ('try again in a few minutes' -> 180s,
'in N minutes' -> N*60 capped at 10min, 'in a moment/shortly' -> 60s).
The parsed window rides error_context -> reset_at -> _exhausted_until,
so the bench matches the provider's own guidance. Provenance-tagged
message-derived windows are dropped for billing-classified failures
(the NousResearch#31273 money-burn protection) while provider-supplied structured
reset_at fields/headers are kept. extract_api_error_context shares the
same parser and tags all message-parsed windows.
2. Cooldown bypass (runtime_provider.py + model_switch.py):
_try_resolve_from_custom_pool now reports pool exhaustion (benched keys
+ billing flags + reset times) and all three fallthrough call sites
filter candidates through _pick_custom_api_key — billing-benched keys
are never served, transient bypasses are logged, and all-benched pools
raise AuthError(code=insufficient_credits) which the gateway already
routes to the fallback chain / billing-exhaustion UX. Two broad
except-Exception blocks in model_switch.py that swallowed this
AuthError and fell back to the raw config key (defeating the filter)
now re-raise it.
3. Recoverable-exhaustion wait (agent_runtime_helpers.py): when rotation
reaches 'no available entries' and the earliest benched entry is
transient (not billing) recovering within 600s, the recovery path waits
(interruptible, gateway-touch heartbeat) and retries instead of aborting.
Pure-billing exhaustion still aborts immediately. Auxiliary background
calls are intentionally exempt (documented at _recover_provider_pool).
4. Fingerprint-aware rotation detection (credential_pool.py):
_upsert_entry compares secret fingerprints instead of raw strings, so
re-hydrating the same borrowed credential (config/env keys stripped on
disk) does not clear persisted benches on every load_pool(). The
incoming fingerprint is carried forward onto borrowed entries even on
the legacy-upgrade path (no stored fingerprint) so subsequent loads
compare correctly. A _exhausted_candidate_unlocked helper DRYs the
shared iteration logic in next_available_at / recoverable_wait_seconds
/ benched_runtime_keys.
Tests: tests/agent/test_credential_pool_402_flapping.py (38 cases) covering
fuzzy parsing, billing guards incl. the full extractor->pool runtime seam,
bench persistence across reload (same-secret survives, new-secret clears,
legacy fingerprint upgrade), resolution bench filter + structured
exhaustion, wait assessment semantics incl. the mixed transient/billing
pool, and adapter compatibility. Existing credential pool + runtime
resolution + model-switch suites pass unchanged.
someaka
force-pushed
the
fix/custom-pool-402-rotation-final
branch
from
August 9, 2026 20:43
5089650 to
5624757
Compare
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.
What this fixes
Real-world failure class observed against a 3-key custom-provider pool
(hyper.charm.land):
benched the funded key for the full 1-hour default TTL — the fuzzy retry
guidance was never parsed.
"no available entries", and the turn aborted as billing-exhausted — while
the funded key recovered in minutes.
to the config singleton key (
custom_providers.api_key), serving keys thepool had benched for billing and burning requests on depleted accounts —
the source of the "random" 402s.
every
load_pool(), because_upsert_entrytreated secret re-hydrationas a key rotation — so billing benches on the config key did not survive
process restarts.
Changes
agent/credential_pool.py):_extract_retry_delay_secondsnow parses fuzzy retry guidance("try again in a few minutes" → 180 s, "in N minutes" → N×60 capped at
600 s, "in a moment/shortly" → 60 s). The parsed window rides
error_context → reset_at → _exhausted_until, so the bench matches theprovider's own guidance. Message-derived windows carry provenance
(
MESSAGE_DERIVED_RESET_TAG) and are dropped for billing-classifiedfailures (preserving the HTTP 402 (payment required) incorrectly retried as transient error — causes runaway token spend #31273 money-burn protection) while
provider-supplied structured
reset_atfields/headers are kept.hermes_cli/runtime_provider.py):_try_resolve_from_custom_poolnow reports pool exhaustion state and allthree fallthrough call sites filter candidates through
_pick_custom_api_key— billing-benched keys are never served. Anall-benched pool raises
AuthError(code="insufficient_credits"), whichthe gateway already routes to the fallback chain / billing-exhaustion UX.
agent/agent_runtime_helpers.py): whenrotation reaches "no available entries" and the earliest benched entry is
transient (not billing) and recovers within 600 s, the recovery path waits
(interruptible, with activity heartbeat) and retries instead of aborting.
Pure-billing exhaustion still aborts immediately. Auxiliary background
calls are intentionally exempt (documented at
_recover_provider_pool).(
agent/credential_pool.py::_upsert_entry): re-hydrating the same secretno longer clears persisted benches; a genuinely new key in config still
does.
Relationship to existing work
numeric phrasings in fix(agent): parse Gemini retry-in cooldowns #51461 — non-numeric guidance ("a few minutes") is the
delta here.
runtime_provider.pyfallthrough site is the same bug class as [Bug] credential pool exhaustion causes misleading 401 instead of real 429/402 #40960/ fix(auth): surface real upstream 429 when all pool entries exhausted (#40960) #57137 (auth.py path), fix(fallback): benched credentials must not disqualify a chain entry #79840 (fallback chain), and fix(auth): respect exhausted cooldowns in codex pool fallback resolver #43277 (codex
resolver): an exhaustion verdict ignored on a fallthrough path. This PR
closes the custom-provider runtime-resolution site.
approach in fix(credential-pool): reduce TTL for HTTP 402 (Payment Required) from 1h to 2min #74946, and is adjacent to [Bug]: CLI/oneshot gets no resolution-time fallback —
hermes -zdies for the whole quota window even with fallback_providers configured #81209/fix(oneshot): consult fallback_providers at resolution time #81517 and Recoverable 402 ("can only afford N tokens") is treated as terminal billing and drops the request #49769/fix(error): treat 402 'can only afford N tokens' as recoverable rate-limit #49785.billing-classified exhaustion never waits and still aborts;
tests/run_agent/test_31273_402_not_retried.pypasses.Tests
tests/agent/test_credential_pool_402_flapping.py— 36 cases coveringfuzzy parsing, billing guards (incl. the full extractor → pool runtime
seam), bench persistence across reload from disk, the resolution bench
filter, wait-assessment semantics (incl. mixed transient/billing pools),
and single-key/no-pool legacy behavior.
suites. One pre-existing unrelated failure
(
test_qwen_oauth_auto_fallthrough_on_auth_failure) fails identically onthe parent commit — qwen-oauth auto-resolution fallthrough, untouched by
this diff.
select()≈ baseline); the exhaustion probe addscost only in the all-benched state; config cache intact.
zero torn writes); scripted flapping simulation 16/16; auth.json integrity
39/39.
Scope note
The borrowed-entry persistence model is unchanged:
_seed_custom_poolstillcreates the
config:<name>entry fromcustom_providers.api_key, andborrowed entries persist without secrets by design (fail-closed disk
boundary). The fingerprint fix makes that boundary cooldown-correct; changing
the persistence model itself is a separate (security-sensitive) change.