1use std::time::Duration;
6
7use chrono::Utc;
8
9use crate::cache::{Cache, LockGuard, MAX_STALE, acquire_lock_async};
10use crate::error::{AppError, Result};
11use crate::usage::AnthropicSnapshot;
12
13use super::creds::{self, OauthCreds};
14use super::oauth;
15use super::types::UsageResponse;
16
17pub const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
18pub const USAGE_BETA_HEADER: &str = "oauth-2025-04-20";
19pub const USAGE_USER_AGENT: &str = "claude-code/2.1.183";
23const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
24const REFRESH_TIMEOUT: Duration = Duration::from_secs(25);
25const LOCK_TIMEOUT: Duration = Duration::from_secs(45);
26
27#[derive(Debug, Clone)]
29pub struct Endpoints {
30 pub usage: String,
31 pub token: String,
32}
33
34impl Default for Endpoints {
35 fn default() -> Self {
36 Self {
37 usage: USAGE_URL.into(),
38 token: oauth::TOKEN_URL.into(),
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct FetchOutcome {
46 pub snapshot: AnthropicSnapshot,
47 pub stale: bool,
50 pub last_error: Option<(u16, String)>,
52 pub cache_age: Option<Duration>,
54}
55
56pub async fn fetch_snapshot(
60 client: &reqwest::Client,
61 creds_target: &creds::CredsTarget,
62 cache: &Cache,
63 endpoints: &Endpoints,
64 cache_ttl: Duration,
65) -> Result<FetchOutcome> {
66 cache.ensure_dir()?;
67 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
68 let credential_lock = acquire_credential_lock(creds_target, LOCK_TIMEOUT).await?;
73
74 let (mut creds, creds_source) = creds::resolve(creds_target)?;
79 let plan_label = creds.claude_ai_oauth.plan_label();
80
81 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
84 && let Ok(outcome) = reuse_cache(bytes, plan_label.clone(), cache, false)
85 {
86 return Ok(outcome);
87 }
88
89 let now = Utc::now().timestamp();
91 let stale_token = oauth::needs_refresh(creds.claude_ai_oauth.expires_at_secs(), now);
92 let have_refresh = oauth::can_refresh(&creds.claude_ai_oauth.refresh_token);
93 if stale_token && !have_refresh {
94 cache.clear_last_error();
102 } else if stale_token {
103 match tokio::time::timeout(
104 REFRESH_TIMEOUT,
105 oauth::refresh(
106 client,
107 &endpoints.token,
108 &creds.claude_ai_oauth.refresh_token,
109 ),
110 )
111 .await
112 {
113 Ok(Ok(rr)) => {
114 creds.claude_ai_oauth.access_token = rr.access_token;
115 let rotated = rr.refresh_token.is_some();
121 if let Some(new_rt) = rr.refresh_token {
122 creds.claude_ai_oauth.refresh_token = new_rt;
123 }
124 creds.claude_ai_oauth.expires_at_ms =
125 Utc::now().timestamp_millis() + (rr.expires_in as i64) * 1000;
126 if let Err(e) = creds::write_back_to(&creds_source, &creds.claude_ai_oauth)
129 && rotated
130 {
131 let msg = format!(
132 "refreshed token could not be saved ({e}); the rotated \
133 refresh token is lost — re-run `claude` to log in again"
134 );
135 cache.write_last_error(0, &msg);
136 return handle_auth_failure(cache, plan_label, false);
137 }
138 }
139 Ok(Err(AppError::Http { status, body })) => {
140 cache.write_last_error(status, &body);
141 return handle_auth_failure(cache, plan_label, false);
142 }
143 Ok(Err(e)) if e.is_transient() => {
144 return handle_auth_failure(cache, plan_label, true);
145 }
146 Ok(Err(e)) => {
147 cache.write_last_error(0, &e.to_string());
148 return handle_auth_failure(cache, plan_label, false);
149 }
150 Err(_elapsed) => {
151 return handle_auth_failure(cache, plan_label, true);
152 }
153 }
154 }
155
156 drop(credential_lock);
159
160 match tokio::time::timeout(
162 HTTP_TIMEOUT,
163 fetch_usage(client, &endpoints.usage, &creds.claude_ai_oauth),
164 )
165 .await
166 {
167 Ok(Ok(bytes)) => {
168 cache.write_payload(&bytes)?;
169 let snap = parse_payload(&bytes, plan_label.clone())?;
170 Ok(FetchOutcome {
171 snapshot: snap,
172 stale: false,
173 last_error: None,
174 cache_age: Some(Duration::ZERO),
175 })
176 }
177 Ok(Err(AppError::Http { status, body })) => {
178 cache.mark_stale();
179 cache.write_last_error(status, &body);
180 fallback_to_cache(cache, plan_label, Some((status, body)))
181 }
182 Ok(Err(e)) if e.is_transient() => {
183 fallback_to_cache_silent(cache, plan_label)
185 }
186 Ok(Err(e)) => {
187 cache.mark_stale();
188 cache.write_last_error(0, &e.to_string());
189 fallback_to_cache(cache, plan_label, Some((0, e.to_string())))
190 }
191 Err(_elapsed) => fallback_to_cache_silent(cache, plan_label),
192 }
193}
194
195async fn acquire_credential_lock(
196 target: &creds::CredsTarget,
197 timeout: Duration,
198) -> Result<Option<LockGuard>> {
199 let creds::CredsTarget::Desktop(desktop) = target else {
200 return Ok(None);
201 };
202 let Some(path) = desktop.coordination_lock() else {
203 return Ok(None);
204 };
205 acquire_lock_async(path, timeout).await.map(Some)
206}
207
208fn reuse_cache(
209 bytes: Vec<u8>,
210 plan_label: String,
211 cache: &Cache,
212 stale: bool,
213) -> Result<FetchOutcome> {
214 let snap = parse_payload(&bytes, plan_label)?;
215 Ok(FetchOutcome {
216 snapshot: snap,
217 stale,
218 last_error: cache.read_last_error(),
219 cache_age: cache.payload_age(),
220 })
221}
222
223fn fallback_to_cache(
224 cache: &Cache,
225 plan_label: String,
226 last_error: Option<(u16, String)>,
227) -> Result<FetchOutcome> {
228 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
229 return Err(AppError::Other("no usable cache".into()));
230 };
231 let snap = parse_payload(&bytes, plan_label)?;
232 Ok(FetchOutcome {
233 snapshot: snap,
234 stale: true,
235 last_error,
236 cache_age: cache.payload_age(),
237 })
238}
239
240fn fallback_to_cache_silent(cache: &Cache, plan_label: String) -> Result<FetchOutcome> {
241 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
242 return Err(AppError::Transport(
243 "no cache and network unreachable".into(),
244 ));
245 };
246 let snap = parse_payload(&bytes, plan_label)?;
247 Ok(FetchOutcome {
248 snapshot: snap,
249 stale: true,
250 last_error: cache.read_last_error(),
251 cache_age: cache.payload_age(),
252 })
253}
254
255fn handle_auth_failure(cache: &Cache, plan_label: String, transient: bool) -> Result<FetchOutcome> {
256 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
257 return if transient {
258 Err(AppError::Transport(
259 "no cache and refresh failed transiently".into(),
260 ))
261 } else {
262 Err(AppError::Credentials(
263 "token refresh failed; run `claude` to re-auth".into(),
264 ))
265 };
266 };
267 let snap = parse_payload(&bytes, plan_label)?;
268 Ok(FetchOutcome {
269 snapshot: snap,
270 stale: true,
271 last_error: cache.read_last_error(),
272 cache_age: cache.payload_age(),
273 })
274}
275
276fn parse_payload(bytes: &[u8], plan_label: String) -> Result<AnthropicSnapshot> {
277 let resp: UsageResponse = serde_json::from_slice(bytes)?;
278 Ok(resp.into_snapshot(plan_label))
279}
280
281async fn fetch_usage(client: &reqwest::Client, url: &str, creds: &OauthCreds) -> Result<Vec<u8>> {
282 let resp = client
283 .get(url)
284 .header("Authorization", format!("Bearer {}", creds.access_token))
285 .header("anthropic-beta", USAGE_BETA_HEADER)
286 .header("User-Agent", USAGE_USER_AGENT)
289 .header("Content-Type", "application/json")
290 .send()
291 .await?;
292
293 let status = resp.status();
294 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
295
296 if status.is_success() {
297 let _: UsageResponse = serde_json::from_slice(&bytes)
300 .map_err(|e| AppError::Schema(format!("usage response unparseable: {e}")))?;
301 Ok(bytes.to_vec())
302 } else {
303 let body = String::from_utf8_lossy(&bytes).into_owned();
304 let msg =
305 oauth::parse_error_body(&body).unwrap_or_else(|| body.chars().take(200).collect());
306 Err(AppError::Http {
307 status: status.as_u16(),
308 body: msg,
309 })
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use std::io::Write;
317 use tempfile::{NamedTempFile, TempDir};
318
319 fn future_creds() -> NamedTempFile {
320 let mut f = NamedTempFile::new().unwrap();
321 let expires_ms = (Utc::now().timestamp_millis()) + 3_600_000;
323 let s = format!(
324 r#"{{"claudeAiOauth":{{
325 "accessToken":"AT","refreshToken":"RT",
326 "expiresAt": {expires_ms},
327 "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
328 }}}}"#
329 );
330 f.write_all(s.as_bytes()).unwrap();
331 f.flush().unwrap();
332 f
333 }
334
335 fn expired_creds_no_refresh() -> NamedTempFile {
338 let mut f = NamedTempFile::new().unwrap();
339 let expires_ms = (Utc::now().timestamp_millis()) - 3_600_000; let s = format!(
341 r#"{{"claudeAiOauth":{{
342 "accessToken":"AT","refreshToken":"",
343 "expiresAt": {expires_ms},
344 "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
345 }}}}"#
346 );
347 f.write_all(s.as_bytes()).unwrap();
348 f.flush().unwrap();
349 f
350 }
351
352 fn cache_fixture() -> (TempDir, Cache) {
353 let td = TempDir::new().unwrap();
354 let cache = Cache::at(td.path().join("anthropic"));
355 cache.ensure_dir().unwrap();
356 (td, cache)
357 }
358
359 #[tokio::test]
360 async fn desktop_refresh_waits_for_the_account_switch_lock() {
361 let tmp = TempDir::new().unwrap();
362 let lock_path = tmp.path().join(".account-switch.lock");
363 let held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(1)).unwrap();
364 let desktop = crate::anthropic::desktop_creds::source_for(
365 &tmp.path().join("config.json"),
366 &tmp.path().join("profile"),
367 false,
368 [0; 16],
369 )
370 .with_coordination_lock(lock_path);
371 let target = creds::CredsTarget::Desktop(desktop);
372
373 let waiter = tokio::spawn(async move {
374 acquire_credential_lock(&target, Duration::from_secs(2))
375 .await
376 .unwrap()
377 .is_some()
378 });
379 tokio::time::sleep(Duration::from_millis(50)).await;
380 assert!(!waiter.is_finished(), "refresh bypassed the switch lock");
381
382 drop(held);
383 assert!(waiter.await.unwrap());
384 }
385
386 #[tokio::test]
387 async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown() {
388 let mut server = mockito::Server::new_async().await;
392 server
393 .mock("GET", "/api/oauth/usage")
394 .with_status(200)
395 .with_body(r#"{"five_hour":{"utilization":42},"seven_day":{"utilization":15}}"#)
396 .create_async()
397 .await;
398
399 let (_td, cache) = cache_fixture();
400 cache.write_payload(b"{ truncated").unwrap();
401
402 let creds = future_creds();
403 let client = reqwest::Client::new();
404 let endpoints = Endpoints {
405 usage: format!("{}/api/oauth/usage", server.url()),
406 token: format!("{}/token", server.url()),
407 };
408 let outcome = fetch_snapshot(
410 &client,
411 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
412 &cache,
413 &endpoints,
414 Duration::from_secs(3600),
415 )
416 .await
417 .unwrap();
418 assert_eq!(outcome.snapshot.session.utilization_pct, 42);
419 assert_ne!(outcome.snapshot.plan, "Unknown");
420 assert!(!outcome.stale);
421 }
422
423 #[tokio::test]
424 async fn fresh_cache_skips_network() {
425 let (_td, cache) = cache_fixture();
426 cache
427 .write_payload(
428 br#"{"five_hour":{"utilization":42,"resets_at":"2026-05-23T17:30:00Z"},
429 "seven_day":{"utilization":15,"resets_at":"2026-05-30T12:00:00Z"}}"#,
430 )
431 .unwrap();
432
433 let creds = future_creds();
434 let client = reqwest::Client::new();
435 let endpoints = Endpoints {
436 usage: "http://localhost:1/should-not-be-called".into(),
437 token: "http://localhost:1/should-not-be-called".into(),
438 };
439 let outcome = fetch_snapshot(
440 &client,
441 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
442 &cache,
443 &endpoints,
444 Duration::from_secs(60),
445 )
446 .await
447 .unwrap();
448 assert_eq!(outcome.snapshot.session.utilization_pct, 42);
449 assert!(!outcome.stale);
450 }
451
452 #[tokio::test]
453 async fn live_fetch_writes_cache_and_returns_snapshot() {
454 let mut server = mockito::Server::new_async().await;
455 let m = server
456 .mock("GET", "/api/oauth/usage")
457 .with_status(200)
458 .with_body(
459 r#"{"five_hour":{"utilization":50,"resets_at":"2026-05-23T17:30:00Z"},
460 "seven_day":{"utilization":25,"resets_at":"2026-05-30T12:00:00Z"}}"#,
461 )
462 .create_async()
463 .await;
464
465 let (_td, cache) = cache_fixture();
466 let creds = future_creds();
467 let client = reqwest::Client::new();
468 let endpoints = Endpoints {
469 usage: format!("{}/api/oauth/usage", server.url()),
470 token: format!("{}/v1/oauth/token", server.url()),
471 };
472 let outcome = fetch_snapshot(
473 &client,
474 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
475 &cache,
476 &endpoints,
477 Duration::from_secs(0),
478 )
479 .await
480 .unwrap();
481 assert_eq!(outcome.snapshot.session.utilization_pct, 50);
482 assert!(!outcome.stale);
483 m.assert_async().await;
484 assert!(cache.maybe_payload().unwrap().is_some());
486 }
487
488 #[tokio::test]
489 async fn http_429_falls_back_to_stale_cache() {
490 let mut server = mockito::Server::new_async().await;
491 server
492 .mock("GET", "/api/oauth/usage")
493 .with_status(429)
494 .with_body(r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#)
495 .create_async()
496 .await;
497
498 let (_td, cache) = cache_fixture();
499 cache
500 .write_payload(
501 br#"{"five_hour":{"utilization":12,"resets_at":"2026-05-23T17:30:00Z"},
502 "seven_day":{"utilization":5,"resets_at":"2026-05-30T12:00:00Z"}}"#,
503 )
504 .unwrap();
505 let creds = future_creds();
507 let client = reqwest::Client::new();
508 let endpoints = Endpoints {
509 usage: format!("{}/api/oauth/usage", server.url()),
510 token: format!("{}/v1/oauth/token", server.url()),
511 };
512 let outcome = fetch_snapshot(
513 &client,
514 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
515 &cache,
516 &endpoints,
517 Duration::from_secs(0),
518 )
519 .await
520 .unwrap();
521 assert!(outcome.stale);
522 assert_eq!(outcome.snapshot.session.utilization_pct, 12);
523 assert_eq!(outcome.last_error.as_ref().map(|(c, _)| *c), Some(429));
524 assert_eq!(
525 outcome.last_error.as_ref().map(|(_, m)| m.as_str()),
526 Some("slow down")
527 );
528 }
529
530 #[tokio::test]
531 async fn empty_refresh_token_skips_refresh_and_fetches_usage() {
532 let mut server = mockito::Server::new_async().await;
535 let refresh = server
536 .mock("POST", "/v1/oauth/token")
537 .with_status(400)
538 .with_body(
539 r#"{"error":{"type":"invalid_request_error","message":"Invalid request format"}}"#,
540 )
541 .expect(0)
542 .create_async()
543 .await;
544 let usage = server
545 .mock("GET", "/api/oauth/usage")
546 .match_header("authorization", "Bearer AT")
547 .match_header("user-agent", USAGE_USER_AGENT)
548 .match_header("anthropic-beta", USAGE_BETA_HEADER)
549 .with_status(200)
550 .with_body(
551 r#"{"five_hour":{"utilization":61,"resets_at":"2026-06-25T17:30:00Z"},
552 "seven_day":{"utilization":31,"resets_at":"2026-06-26T12:00:00Z"}}"#,
553 )
554 .create_async()
555 .await;
556
557 let (_td, cache) = cache_fixture();
558 cache
559 .write_payload(
560 br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
561 "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
562 )
563 .unwrap();
564
565 let creds = expired_creds_no_refresh();
566 let client = reqwest::Client::new();
567 let endpoints = Endpoints {
568 usage: format!("{}/api/oauth/usage", server.url()),
569 token: format!("{}/v1/oauth/token", server.url()),
570 };
571 let outcome = fetch_snapshot(
572 &client,
573 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
574 &cache,
575 &endpoints,
576 Duration::from_secs(0),
577 )
578 .await
579 .unwrap();
580
581 assert!(!outcome.stale);
582 assert_eq!(outcome.snapshot.session.utilization_pct, 61);
583 assert!(
584 outcome.last_error.is_none(),
585 "empty-refresh path must not poison .last_error, got {:?}",
586 outcome.last_error
587 );
588 refresh.assert_async().await; usage.assert_async().await; }
591
592 #[tokio::test]
593 async fn empty_refresh_token_clears_old_last_error_on_transient_fallback() {
594 let mut server = mockito::Server::new_async().await;
595 let refresh = server
596 .mock("POST", "/v1/oauth/token")
597 .expect(0)
598 .create_async()
599 .await;
600
601 let (_td, cache) = cache_fixture();
602 cache
603 .write_payload(
604 br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
605 "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
606 )
607 .unwrap();
608 cache.write_last_error(400, "Invalid request format");
609
610 let creds = expired_creds_no_refresh();
611 let client = reqwest::Client::builder()
612 .timeout(Duration::from_millis(200))
613 .build()
614 .unwrap();
615 let endpoints = Endpoints {
616 usage: "http://127.0.0.1:1/api/oauth/usage".into(),
617 token: format!("{}/v1/oauth/token", server.url()),
618 };
619 let outcome = fetch_snapshot(
620 &client,
621 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
622 &cache,
623 &endpoints,
624 Duration::from_secs(0),
625 )
626 .await
627 .unwrap();
628
629 assert!(outcome.stale);
630 assert_eq!(outcome.snapshot.session.utilization_pct, 17);
631 assert!(outcome.last_error.is_none());
632 assert!(cache.read_last_error().is_none());
633 refresh.assert_async().await;
634 }
635
636 #[tokio::test]
637 async fn no_cache_and_no_network_returns_error() {
638 let (_td, cache) = cache_fixture();
640 let creds = future_creds();
641 let client = reqwest::Client::builder()
642 .timeout(Duration::from_millis(200))
643 .build()
644 .unwrap();
645 let endpoints = Endpoints {
646 usage: "http://127.0.0.1:1/api/oauth/usage".into(),
647 token: "http://127.0.0.1:1/v1/oauth/token".into(),
648 };
649 let err = fetch_snapshot(
650 &client,
651 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
652 &cache,
653 &endpoints,
654 Duration::from_secs(0),
655 )
656 .await
657 .unwrap_err();
658 assert!(err.is_transient(), "expected transient error, got {err:?}");
659 }
660}