1use std::sync::Weak;
27
28use forge_gateway::ProbeError;
29use forge_primitives::usage::UsageSnapshot;
30use forge_primitives::usage::oauth::OauthUsageError;
31
32use forge_gateway::{AccountKey, AccountPool, LoadingState};
33
34use crate::workspace::Workspace;
35
36fn settle_probe_result(
41 pool: &AccountPool,
42 key: &AccountKey,
43 result: &Result<UsageSnapshot, ProbeError>,
44) -> LoadingState {
45 match result {
46 Ok(snapshot) => {
47 pool.set_usage(key, snapshot.clone());
48 LoadingState::Ready
49 }
50 Err(ProbeError::Unmappable(_)) => {
51 pool.clear_last_error(key);
54 pool.set_loading(key, LoadingState::Bailed);
55 LoadingState::Bailed
56 }
57 Err(err) => {
58 let failure_class = crate::workspace::classify_oauth_usage_error(err);
59 let retry_after = match err {
60 ProbeError::Fetch(OauthUsageError::RateLimited { retry_after, .. }) => *retry_after,
61 _ => None,
62 };
63 pool.set_last_error(key, failure_class, retry_after);
64 pool.set_loading(key, LoadingState::Bailed);
67 if let Some(snapshot) = err.rate_limited_snapshot() {
70 pool.set_limit_reading(key, snapshot.clone());
71 }
72 LoadingState::Bailed
73 }
74 }
75}
76
77pub async fn run_account_loading(account_key: AccountKey, workspace_weak: Weak<Workspace>) {
84 let Some(workspace) = workspace_weak.upgrade() else {
85 return;
87 };
88 let pool = workspace.account_pool();
89 let Some(provider) = pool.provider(&account_key) else {
90 tracing::warn!(
91 target: "forge_workspace::account_loader",
92 account = %account_key.0,
93 "no provider for the account key; skipping its boot probe",
94 );
95 return;
96 };
97 let account_env = pool.env(&account_key).unwrap_or_default();
98 let probe_result = crate::provider_probe::probe_via_backend(provider, &account_env).await;
101 match &probe_result {
102 Ok(_) => tracing::info!(
103 target: "forge_workspace::account_loader",
104 account = %account_key.0,
105 "boot probe reached Ready",
106 ),
107 Err(err) => tracing::warn!(
108 target: "forge_workspace::account_loader",
109 account = %account_key.0,
110 error = %err,
111 "boot probe failed; account settled Bailed; the usage poller will re-probe it",
112 ),
113 }
114 settle_probe_result(pool, &account_key, &probe_result);
115 workspace.announce_accounts_changed();
116}
117
118#[cfg(test)]
119#[allow(clippy::expect_used, clippy::unwrap_used)]
120mod tests {
121 use super::*;
122 use forge_gateway::ProbeError;
123 use forge_gateway::UsageFetchStatus;
124 use forge_primitives::usage::oauth::OauthUsageError;
125 use forge_primitives::usage::{UsageSnapshot, UsageSourceKind, UsageWindow};
126 use std::time::Duration;
127
128 fn states_with_one_account() -> AccountPool {
130 AccountPool::new(&[crate::config::LoadedAccount {
131 display_name: "test".to_owned(),
132 provider: forge_primitives::account::Provider::Anthropic,
133 base_url: None,
134 models: vec!["claude-sonnet-5".to_owned()],
135 model_aliases: std::collections::HashMap::new(),
136 model_slugs: std::collections::HashMap::new(),
137 env: std::collections::HashMap::new(),
138 }])
139 }
140
141 fn snapshot() -> UsageSnapshot {
142 UsageSnapshot {
143 source: UsageSourceKind::Oauth,
144 fetched_at: std::time::SystemTime::UNIX_EPOCH,
145 five_hour: Some(UsageWindow {
146 utilization: 10.0,
147 resets_at: Some(std::time::SystemTime::now() + Duration::from_secs(3600)),
148 reset_description: None,
149 status: None,
150 }),
151 seven_day: Some(UsageWindow {
152 utilization: 20.0,
153 resets_at: Some(std::time::SystemTime::now() + Duration::from_secs(3600)),
154 reset_description: None,
155 status: None,
156 }),
157 seven_day_opus: None,
158 seven_day_sonnet: None,
159 extra_usage: None,
160 spend: None,
161 balance: None,
162 }
163 }
164
165 #[test]
166 fn settle_probe_result_bails_rate_limited_with_status_and_retry_after() {
167 let states = states_with_one_account();
168 let key = AccountKey("test".to_owned());
169 let state = settle_probe_result(
170 &states,
171 &key,
172 &Err(ProbeError::Fetch(OauthUsageError::RateLimited {
173 retry_after: Some(Duration::from_secs(3600)),
174 snapshot: None,
175 })),
176 );
177 assert_eq!(state, LoadingState::Bailed);
178 assert_eq!(
179 states.loading_state(&key),
180 LoadingState::Bailed,
181 "the terminal settle is stored, not just returned",
182 );
183 assert_eq!(
184 states.usage_error("test"),
185 Some(UsageFetchStatus::RateLimited),
186 "the rate-limit class is recorded for the row's reason text",
187 );
188 assert!(
189 states.latest_reading("test").is_none(),
190 "a 429 with no unified headers has no reading to keep",
191 );
192 let gap = states.next_probe_after(&key);
197 assert!(
198 gap.is_some_and(|g| g > Duration::from_secs(599) && g <= Duration::from_secs(601)),
199 "Retry-After retained (clamped) in the probe schedule; got {gap:?}",
200 );
201 }
202
203 #[test]
207 fn settle_probe_result_keeps_a_rate_limited_readings_windows() {
208 let states = states_with_one_account();
209 let key = AccountKey("test".to_owned());
210 let state = settle_probe_result(
211 &states,
212 &key,
213 &Err(ProbeError::Fetch(OauthUsageError::RateLimited {
214 retry_after: Some(Duration::from_secs(3600)),
215 snapshot: Some(Box::new(snapshot())),
216 })),
217 );
218 assert_eq!(state, LoadingState::Bailed, "a 429 does not heal the account");
219 assert_eq!(states.loading_state(&key), LoadingState::Bailed);
220 assert_eq!(states.usage_error("test"), Some(UsageFetchStatus::RateLimited));
221 let reading = states.latest_reading("test").expect("the 429's windows survived the bail");
222 assert!(reading.five_hour.is_some(), "the 5h window was dropped");
223 assert!(reading.seven_day.is_some(), "the 7d window was dropped");
224 }
225
226 #[test]
227 fn settle_probe_result_bails_unauthorized_with_its_status() {
228 let states = states_with_one_account();
229 let key = AccountKey("test".to_owned());
230 let state = settle_probe_result(
231 &states,
232 &key,
233 &Err(ProbeError::Fetch(OauthUsageError::Unauthorized(401))),
234 );
235 assert_eq!(state, LoadingState::Bailed);
236 assert_eq!(
237 states.loading_state(&key),
238 LoadingState::Bailed,
239 "the terminal settle is stored, not just returned",
240 );
241 assert_eq!(states.usage_error("test"), Some(UsageFetchStatus::Unauthorized),);
242 }
243
244 #[test]
245 fn settle_probe_result_bails_unmappable_without_an_error_record() {
246 let states = states_with_one_account();
247 let key = AccountKey("test".to_owned());
248 states.set_last_error(&key, UsageFetchStatus::RateLimited, None);
251 let state = settle_probe_result(
252 &states,
253 &key,
254 &Err(ProbeError::Unmappable("shape drift".to_owned())),
255 );
256 assert_eq!(state, LoadingState::Bailed);
257 assert_eq!(
258 states.loading_state(&key),
259 LoadingState::Bailed,
260 "the terminal settle is stored, not just returned",
261 );
262 assert!(
263 states.usage_error("test").is_none(),
264 "Unmappable records no error, preserving today's nuance",
265 );
266 }
267
268 #[test]
269 fn settle_probe_result_settles_successful_probe_ready() {
270 let states = states_with_one_account();
271 let key = AccountKey("test".to_owned());
272 let state = settle_probe_result(&states, &key, &Ok(snapshot()));
273 assert_eq!(state, LoadingState::Ready);
274 assert_eq!(
275 states.loading_state(&key),
276 LoadingState::Ready,
277 "the terminal settle is stored, not just returned",
278 );
279 assert!(states.usage("test").is_some(), "the snapshot is cached");
280 }
281}