From 6ec85dbf01d93dbca5ac80de15c66e0186547c9d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 7 Jul 2026 20:01:55 +0300 Subject: [PATCH] fix(tui): make usage heartbeat reset-aware and auto-switch selection smarter Under rate limits the auth-profile usage heartbeat kept re-polling an already-exhausted profile every interval once its cached snapshot lapsed freshness, hammering the usage endpoint until reset (the Jul-7 reset incidents). Auto-switch could also optimistically pick an Unknown or just-failed profile over a known-healthy one. - Track the earliest future reset for a profile whose cached codex usage is exhausted and suppress heartbeats for it until that reset, falling back to the normal interval/backoff when the reset is missing or has elapsed so heartbeats are never permanently blocked. - Ordered auto-switch now returns the first healthy profile in configured order and only defers to an Unknown profile when nothing is known healthy, instead of grabbing the first Unknown it sees. - Skip auto-switching onto a profile whose most recent usage heartbeat failed within the failure backoff. Adds core + TUI tests; preserves config defaults and TOML back-compat. Co-Authored-By: Claude Opus 4.8 --- codex-rs/core/src/usage_profile_health.rs | 203 +++++++++++++++++- codex-rs/tui/src/chatwidget.rs | 4 + .../tui/src/chatwidget/auth_profile_popups.rs | 16 +- codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/rate_limits.rs | 62 +++++- .../chatwidget/tests/popups_and_settings.rs | 32 +++ .../src/chatwidget/tests/status_and_layout.rs | 32 +++ .../src/chatwidget/usage_profile_broker.rs | 30 ++- 8 files changed, 365 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/usage_profile_health.rs b/codex-rs/core/src/usage_profile_health.rs index c446a6f96..ce110008f 100644 --- a/codex-rs/core/src/usage_profile_health.rs +++ b/codex-rs/core/src/usage_profile_health.rs @@ -151,6 +151,32 @@ pub fn exhausted_auto_switch_window_for_snapshot( .find_map(exhausted_auto_switch_window_for_limit) } +/// Earliest future reset timestamp (unix seconds) among exhausted (`used_percent >= 100`) +/// codex windows in `snapshot`. +/// +/// Returns `None` when the limit is not codex, nothing is exhausted, or every exhausted +/// window's reset is missing or already in the past. Callers use this to suppress usage +/// heartbeats for a profile that is already capped until it resets, instead of re-polling +/// the usage endpoint every heartbeat interval (which just hammers a limit we already know +/// about). When the reset is unknown, `None` lets the caller fall back to the normal +/// interval/backoff so heartbeats are never permanently blocked. +pub fn earliest_exhausted_reset_at( + snapshot: &UsageProfileRateLimitSnapshot<'_>, + now_unix_secs: i64, +) -> Option { + if !is_codex_limit(snapshot) { + return None; + } + + [snapshot.secondary, snapshot.primary] + .into_iter() + .flatten() + .filter(|window| window.used_percent >= 100.0) + .filter_map(|window| window.resets_at) + .filter(|reset_at| *reset_at > now_unix_secs) + .min() +} + pub fn usage_health_for_snapshots( snapshots: &[UsageProfileRateLimitSnapshot<'_>], config: &AuthProfileAutoSwitchConfig, @@ -224,6 +250,12 @@ pub fn choose_profile_for_auto_switch( let mut retry_at = None; match config.strategy { AuthProfileAutoSwitchStrategy::Ordered => { + // Respect the configured order, but never optimistically switch to an + // Unknown profile when a later candidate is known to be healthy: a + // known-healthy profile is always a safer switch target than one whose + // usage we have not confirmed. + let mut first_healthy = None; + let mut first_unknown = None; for candidate in candidates { match health_by_profile .get(candidate) @@ -231,22 +263,32 @@ pub fn choose_profile_for_auto_switch( .unwrap_or(UsageProfileHealth::Unknown) { UsageProfileHealth::Healthy(_) => { - return UsageProfileSelection::selected( - candidate.clone(), - UsageProfileSelectionReason::SelectedHealthyProfile, - ); + if first_healthy.is_none() { + first_healthy = Some(candidate.as_str()); + } } UsageProfileHealth::Unknown => { - return UsageProfileSelection::selected( - candidate.clone(), - UsageProfileSelectionReason::SelectedUnknownProfile, - ); + if first_unknown.is_none() { + first_unknown = Some(candidate.as_str()); + } } UsageProfileHealth::Exhausted { retry_at: profile_retry_at, } => merge_retry_at(&mut retry_at, profile_retry_at), } } + if let Some(profile) = first_healthy { + return UsageProfileSelection::selected( + profile.to_string(), + UsageProfileSelectionReason::SelectedHealthyProfile, + ); + } + if let Some(profile) = first_unknown { + return UsageProfileSelection::selected( + profile.to_string(), + UsageProfileSelectionReason::SelectedUnknownProfile, + ); + } } AuthProfileAutoSwitchStrategy::HighestAvailable => { let mut best: Option<(&str, UsageProfileScore)> = None; @@ -644,6 +686,151 @@ mod tests { ); } + fn ordered_config() -> AuthProfileAutoSwitchConfig { + AuthProfileAutoSwitchConfig { + enabled: true, + strategy: AuthProfileAutoSwitchStrategy::Ordered, + ..Default::default() + } + } + + #[test] + fn ordered_prefers_healthy_over_earlier_unknown() { + let health_by_profile = BTreeMap::from([ + ("first".to_string(), UsageProfileHealth::Unknown), + ( + "second".to_string(), + UsageProfileHealth::Healthy(UsageProfileScore { + trigger_remaining_percent: 40.0, + limiting_remaining_percent: 40.0, + }), + ), + ]); + + assert_eq!( + UsageProfileSelection { + selected_profile: Some("second".to_string()), + retry_at: None, + reason: UsageProfileSelectionReason::SelectedHealthyProfile, + }, + choose_profile_for_auto_switch( + &ordered_config(), + &["first".to_string(), "second".to_string()], + &health_by_profile, + ) + ); + } + + #[test] + fn ordered_keeps_first_healthy_in_configured_order() { + let health_by_profile = BTreeMap::from([ + ( + "first".to_string(), + UsageProfileHealth::Healthy(UsageProfileScore { + trigger_remaining_percent: 20.0, + limiting_remaining_percent: 20.0, + }), + ), + ( + "second".to_string(), + UsageProfileHealth::Healthy(UsageProfileScore { + trigger_remaining_percent: 90.0, + limiting_remaining_percent: 90.0, + }), + ), + ]); + + assert_eq!( + UsageProfileSelection { + selected_profile: Some("first".to_string()), + retry_at: None, + reason: UsageProfileSelectionReason::SelectedHealthyProfile, + }, + choose_profile_for_auto_switch( + &ordered_config(), + &["first".to_string(), "second".to_string()], + &health_by_profile, + ) + ); + } + + #[test] + fn ordered_falls_back_to_first_unknown_when_no_healthy() { + let health_by_profile = BTreeMap::from([( + "first".to_string(), + UsageProfileHealth::Exhausted { + retry_at: Some(500), + }, + )]); + + assert_eq!( + UsageProfileSelection { + selected_profile: Some("second".to_string()), + retry_at: None, + reason: UsageProfileSelectionReason::SelectedUnknownProfile, + }, + choose_profile_for_auto_switch( + &ordered_config(), + &["first".to_string(), "second".to_string()], + &health_by_profile, + ) + ); + } + + #[test] + fn earliest_exhausted_reset_at_returns_future_reset() { + assert_eq!( + Some(1_000), + earliest_exhausted_reset_at( + &snapshot( + Some(window(100.0, MINUTES_PER_5_HOURS, Some(1_000))), + Some(window(100.0, MINUTES_PER_WEEK, Some(2_000))), + ), + 500, + ) + ); + } + + #[test] + fn earliest_exhausted_reset_at_ignores_unexhausted_and_past_resets() { + // Not exhausted -> None. + assert_eq!( + None, + earliest_exhausted_reset_at( + &snapshot(Some(window(80.0, MINUTES_PER_5_HOURS, Some(1_000))), None), + 500, + ) + ); + // Exhausted but reset already elapsed -> None (fall back to normal interval). + assert_eq!( + None, + earliest_exhausted_reset_at( + &snapshot(Some(window(100.0, MINUTES_PER_5_HOURS, Some(400))), None), + 500, + ) + ); + // Exhausted but reset unknown -> None. + assert_eq!( + None, + earliest_exhausted_reset_at( + &snapshot(Some(window(100.0, MINUTES_PER_5_HOURS, None)), None), + 500, + ) + ); + } + + #[test] + fn earliest_exhausted_reset_at_ignores_non_codex_limits() { + let snapshot = UsageProfileRateLimitSnapshot { + limit_id: Some("not-codex"), + limit_name: None, + primary: Some(window(100.0, MINUTES_PER_5_HOURS, Some(1_000))), + secondary: None, + }; + + assert_eq!(None, earliest_exhausted_reset_at(&snapshot, 500)); + } + #[test] fn exhausted_candidates_merge_earliest_retry_timestamp() { let health_by_profile = BTreeMap::from([ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c0bee7a8d..91776418b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -611,6 +611,10 @@ pub(crate) struct ChatWidget { BTreeMap, BTreeMap>, auth_profile_usage_heartbeat_requested_at_by_profile: BTreeMap, Instant>, auth_profile_usage_heartbeat_failed_at_by_profile: BTreeMap, Instant>, + /// Earliest known future reset (unix seconds) for a profile whose cached codex usage is + /// exhausted. While the reset is in the future we suppress usage heartbeats for that + /// profile instead of re-polling a limit we already know is capped. + auth_profile_usage_exhausted_reset_at_by_profile: BTreeMap, i64>, rate_limit_poller: Option>, auth_profile_auto_switch_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, diff --git a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs index b2a31d3a0..fe4643c34 100644 --- a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs +++ b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs @@ -22,7 +22,8 @@ use std::time::Duration; use std::time::Instant; const AUTH_PROFILE_LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); -const AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF: Duration = Duration::from_secs(5 * 60); +pub(super) const AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF: Duration = + Duration::from_secs(5 * 60); const AUTH_PROFILE_POPUP_VIEW_ID: &str = "auth-profile-selection"; impl ChatWidget { @@ -419,6 +420,19 @@ impl ChatWidget { } let profile = profile.map(str::to_string); + // Reset-aware suppression: if the profile's cached codex usage is exhausted and its + // reported reset is still in the future, do not re-poll the usage endpoint. Nothing + // will change until the reset, so polling every interval just hammers a limit we + // already know about. Once the reset elapses (or if it was never reported) this + // check falls through to the normal interval/backoff below, so heartbeats resume. + if self + .auth_profile_usage_exhausted_reset_at_by_profile + .get(&profile) + .is_some_and(|reset_at| *reset_at > chrono::Utc::now().timestamp()) + { + return false; + } + if self .auth_profile_usage_heartbeat_failed_at_by_profile .get(&profile) diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 72838c83e..cc76dd75e 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -132,6 +132,7 @@ impl ChatWidget { auth_profile_rate_limit_snapshots_by_profile: BTreeMap::new(), auth_profile_usage_heartbeat_requested_at_by_profile: BTreeMap::new(), auth_profile_usage_heartbeat_failed_at_by_profile: BTreeMap::new(), + auth_profile_usage_exhausted_reset_at_by_profile: BTreeMap::new(), rate_limit_poller: None, auth_profile_auto_switch_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index bff2e4786..7ead37d0e 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -3,7 +3,9 @@ use super::*; use crate::chatwidget::usage_profile_broker::UsageProfileAutoSwitchWindow; use crate::chatwidget::usage_profile_broker::auth_profile_auto_switch_target as broker_auth_profile_auto_switch_target; +use crate::chatwidget::auth_profile_popups::AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF; use crate::chatwidget::usage_profile_broker::auto_switch_trigger_key; +use crate::chatwidget::usage_profile_broker::earliest_exhausted_reset_at; use crate::chatwidget::usage_profile_broker::exhausted_auto_switch_window; use crate::chatwidget::usage_profile_broker::exhausted_auto_switch_window_for_snapshot; use crate::chatwidget::usage_profile_broker::fallback_limit_label as broker_fallback_limit_label; @@ -146,7 +148,7 @@ impl ChatWidget { ) { let captured_at = Local::now(); let mut displays = BTreeMap::new(); - for snapshot in snapshots { + for snapshot in &snapshots { let limit_id = snapshot .limit_id .clone() @@ -157,10 +159,12 @@ impl ChatWidget { .unwrap_or_else(|| limit_id.clone()); displays.insert( limit_id, - rate_limit_snapshot_display_for_limit(&snapshot, limit_label, captured_at), + rate_limit_snapshot_display_for_limit(snapshot, limit_label, captured_at), ); } + self.update_auth_profile_usage_exhaustion(&profile, &snapshots); + if displays.is_empty() { self.auth_profile_rate_limit_snapshots_by_profile .remove(&profile); @@ -170,6 +174,37 @@ impl ChatWidget { } } + /// Record whether `profile`'s codex usage is exhausted with a future reset so we can + /// suppress usage heartbeats for it until it resets (see + /// `should_request_auth_profile_usage_heartbeat`). + fn update_auth_profile_usage_exhaustion( + &mut self, + profile: &Option, + snapshots: &[RateLimitSnapshot], + ) { + let now = chrono::Utc::now().timestamp(); + let reset_at = snapshots + .iter() + .filter_map(|snapshot| { + let is_codex_limit = snapshot + .limit_id + .as_deref() + .is_none_or(|limit_id| limit_id.eq_ignore_ascii_case("codex")); + earliest_exhausted_reset_at(snapshot, is_codex_limit, now) + }) + .min(); + match reset_at { + Some(reset_at) => { + self.auth_profile_usage_exhausted_reset_at_by_profile + .insert(profile.clone(), reset_at); + } + None => { + self.auth_profile_usage_exhausted_reset_at_by_profile + .remove(profile); + } + } + } + pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) { // Rolling app-server notifications are sparse. Preserve metadata learned from the full read. self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate); @@ -272,6 +307,13 @@ impl ChatWidget { } self.update_auth_profile_auto_switch_snapshot(&limit_id, &snapshot, is_codex_limit); + if is_codex_limit { + let selected_profile = self.config.selected_auth_profile.clone(); + self.update_auth_profile_usage_exhaustion( + &selected_profile, + std::slice::from_ref(&snapshot), + ); + } self.maybe_auto_switch_auth_profile_for_rate_limit(&limit_id, &snapshot); let mut display = @@ -296,6 +338,8 @@ impl ChatWidget { self.rate_limit_snapshots_by_limit_id.clear(); self.auth_profile_rate_limit_snapshots_by_profile .remove(&self.config.selected_auth_profile); + self.auth_profile_usage_exhausted_reset_at_by_profile + .remove(&self.config.selected_auth_profile); self.auth_profile_auto_switch_snapshots_by_limit_id.clear(); self.codex_rate_limit_reached_type = None; } @@ -410,6 +454,18 @@ impl ChatWidget { self.refresh_pending_input_preview(); } + /// Named profiles whose most recent usage heartbeat failed within the failure backoff. + /// Used to avoid auto-switching onto a profile we could not confirm is available. + fn recently_failed_auth_profile_usage_heartbeats(&self) -> std::collections::HashSet { + self.auth_profile_usage_heartbeat_failed_at_by_profile + .iter() + .filter(|(_, failed_at)| { + failed_at.elapsed() < AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF + }) + .filter_map(|(profile, _)| profile.clone()) + .collect() + } + fn auth_profile_auto_switch_target( &mut self, limit_id: &str, @@ -434,11 +490,13 @@ impl ChatWidget { return None; } }; + let recently_failed = self.recently_failed_auth_profile_usage_heartbeats(); if let Some(target) = broker_auth_profile_auto_switch_target( &self.config.auth_profile_auto_switch, self.config.selected_auth_profile.as_deref(), &profiles, &self.auth_profile_rate_limit_snapshots_by_profile, + &recently_failed, limit_id, window, ) { diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index fc94c2d06..f6643dc18 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3278,6 +3278,38 @@ async fn usage_heartbeat_failure_backs_off_profile_refresh() { ); } +#[tokio::test] +async fn usage_heartbeat_suppressed_for_exhausted_profile_until_reset() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + save_popup_chatgpt_auth_profile(&chat, "work", "work@example.com"); + + // A cached-but-stale exhausted snapshot whose reset is still in the future suppresses the + // heartbeat: nothing will change until the reset, so we must not re-poll the usage endpoint. + let stale_exhausted = rate_limit_snapshot_display_for_limit( + &profile_usage_snapshot(/*secondary_used_percent*/ 100, /*primary_used_percent*/ 100), + "codex".to_string(), + Local::now() - chrono::Duration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES + 1), + ); + chat.auth_profile_rate_limit_snapshots_by_profile.insert( + Some("work".to_string()), + BTreeMap::from([("codex".to_string(), stale_exhausted)]), + ); + chat.auth_profile_usage_exhausted_reset_at_by_profile + .insert(Some("work".to_string()), chrono::Utc::now().timestamp() + 3600); + + assert!(chat.auth_profile_usage_refresh_targets().is_empty()); + + // Once the reset has elapsed the suppression lapses and heartbeats resume (graceful + // fallback so a missing/stale reset can never permanently block the profile). + chat.auth_profile_usage_exhausted_reset_at_by_profile + .insert(Some("work".to_string()), chrono::Utc::now().timestamp() - 1); + assert_eq!( + chat.auth_profile_usage_refresh_targets(), + vec![RateLimitRefreshTarget::Named("work".to_string())] + ); +} + fn assert_no_rate_limit_refresh_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) { while let Ok(event) = rx.try_recv() { assert!( diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 1aa8c0563..1f22fdabf 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1566,6 +1566,38 @@ async fn auto_switch_skips_known_exhausted_profile_for_unknown_candidate() { } } +#[tokio::test] +async fn auto_switch_skips_recently_failed_profile_for_unknown_candidate() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + save_test_auth_profile(&chat, "backup"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.profiles = vec![ + "work".to_string(), + "personal".to_string(), + "backup".to_string(), + ]; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + // "personal" just failed a usage heartbeat, so it must not be chosen even though it is + // earlier in the configured order and otherwise Unknown; "backup" (also Unknown) wins. + chat.record_auth_profile_usage_heartbeat_failure(Some("personal".to_string())); + + chat.on_rate_limit_snapshot(Some(rate_limit_snapshot_for_window( + /*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 123, + ))); + + match rx.try_recv() { + Ok(AppEvent::SwitchAuthProfile { profile, .. }) => { + assert_eq!(profile.as_deref(), Some("backup")); + } + other => panic!("expected auth profile switch event, got {other:?}"), + } +} + #[tokio::test] async fn cached_exhausted_limit_auto_switches_before_next_prompt_after_being_enabled() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs index f74b93597..5f7db40a9 100644 --- a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs +++ b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs @@ -49,6 +49,17 @@ pub(super) fn exhausted_auto_switch_window_for_snapshot( .map(tui_auto_switch_window) } +pub(super) fn earliest_exhausted_reset_at( + snapshot: &RateLimitSnapshot, + is_codex_limit: bool, + now_unix_secs: i64, +) -> Option { + usage_profile_health::earliest_exhausted_reset_at( + &app_server_rate_limit_snapshot(snapshot, is_codex_limit), + now_unix_secs, + ) +} + pub(super) fn auto_switch_trigger_key( limit_id: &str, window: &UsageProfileAutoSwitchWindow, @@ -68,6 +79,7 @@ pub(super) fn auth_profile_auto_switch_target( Option, BTreeMap, >, + recently_failed_profiles: &HashSet, limit_id: &str, window: &UsageProfileAutoSwitchWindow, ) -> Option { @@ -78,6 +90,7 @@ pub(super) fn auth_profile_auto_switch_target( | AuthProfileAutoSwitchStrategy::Ordered => healthiest_auth_profile_for_auto_switch( config, cached_snapshots_by_profile, + recently_failed_profiles, &candidates, window, ), @@ -143,6 +156,7 @@ fn healthiest_auth_profile_for_auto_switch( Option, BTreeMap, >, + recently_failed_profiles: &HashSet, candidates: &[String], window: &UsageProfileAutoSwitchWindow, ) -> Option { @@ -152,10 +166,18 @@ fn healthiest_auth_profile_for_auto_switch( for profile in candidates { let profile_key = Some(profile.clone()); let snapshots = cached_snapshots_by_profile.get(&profile_key); - health_by_profile.insert( - profile.clone(), - auth_profile_usage_health_for_auto_switch(snapshots, window, config, freshness), - ); + let mut health = + auth_profile_usage_health_for_auto_switch(snapshots, window, config, freshness); + // Do not optimistically switch onto a profile whose most recent usage heartbeat + // failed: we could not confirm it is available, so treat an otherwise-Unknown + // profile as unavailable rather than a switch target. The failure backoff clears + // this after a short window, so the profile is reconsidered once it recovers. + if recently_failed_profiles.contains(profile) + && matches!(health, UsageProfileHealth::Unknown) + { + health = UsageProfileHealth::Exhausted { retry_at: None }; + } + health_by_profile.insert(profile.clone(), health); } choose_profile_for_auto_switch(config, candidates, &health_by_profile).selected_profile