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 let last_error = Some(cache.write_last_error(status, &body));
180 fallback_to_cache(
181 cache,
182 plan_label,
183 last_error,
184 AppError::Http { status, body },
185 )
186 }
187 Ok(Err(e)) if e.is_transient() => {
188 fallback_to_cache_silent(cache, plan_label, e)
190 }
191 Ok(Err(e)) => {
192 cache.mark_stale();
193 let last_error = Some(cache.write_last_error(0, &e.to_string()));
194 fallback_to_cache(cache, plan_label, last_error, e)
195 }
196 Err(_elapsed) => fallback_to_cache_silent(
197 cache,
198 plan_label,
199 AppError::Transport("usage request timed out".into()),
200 ),
201 }
202}
203
204async fn acquire_credential_lock(
205 target: &creds::CredsTarget,
206 timeout: Duration,
207) -> Result<Option<LockGuard>> {
208 let creds::CredsTarget::Desktop(desktop) = target else {
209 return Ok(None);
210 };
211 let Some(path) = desktop.coordination_lock() else {
212 return Ok(None);
213 };
214 acquire_lock_async(path, timeout).await.map(Some)
215}
216
217fn reuse_cache(
218 bytes: Vec<u8>,
219 plan_label: String,
220 cache: &Cache,
221 stale: bool,
222) -> Result<FetchOutcome> {
223 let snap = parse_payload(&bytes, plan_label)?;
224 Ok(FetchOutcome {
225 snapshot: snap,
226 stale,
227 last_error: cache.read_last_error(),
228 cache_age: cache.payload_age(),
229 })
230}
231
232fn fallback_to_cache(
237 cache: &Cache,
238 plan_label: String,
239 last_error: Option<(u16, String)>,
240 original: AppError,
241) -> Result<FetchOutcome> {
242 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
243 return Err(original);
244 };
245 let snap = parse_payload(&bytes, plan_label)?;
246 Ok(FetchOutcome {
247 snapshot: snap,
248 stale: true,
249 last_error,
250 cache_age: cache.payload_age(),
251 })
252}
253
254fn fallback_to_cache_silent(
255 cache: &Cache,
256 plan_label: String,
257 original: AppError,
258) -> Result<FetchOutcome> {
259 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
260 return Err(original);
261 };
262 let snap = parse_payload(&bytes, plan_label)?;
263 Ok(FetchOutcome {
264 snapshot: snap,
265 stale: true,
266 last_error: cache.read_last_error(),
267 cache_age: cache.payload_age(),
268 })
269}
270
271fn handle_auth_failure(cache: &Cache, plan_label: String, transient: bool) -> Result<FetchOutcome> {
272 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
273 return if transient {
274 Err(AppError::Transport(
275 "no cache and refresh failed transiently".into(),
276 ))
277 } else {
278 Err(AppError::Credentials(
279 "token refresh failed; run `claude` to re-auth".into(),
280 ))
281 };
282 };
283 let snap = parse_payload(&bytes, plan_label)?;
284 Ok(FetchOutcome {
285 snapshot: snap,
286 stale: true,
287 last_error: cache.read_last_error(),
288 cache_age: cache.payload_age(),
289 })
290}
291
292fn parse_payload(bytes: &[u8], plan_label: String) -> Result<AnthropicSnapshot> {
293 let resp: UsageResponse = serde_json::from_slice(bytes)?;
294 Ok(resp.into_snapshot(plan_label))
295}
296
297async fn fetch_usage(client: &reqwest::Client, url: &str, creds: &OauthCreds) -> Result<Vec<u8>> {
298 let resp = client
299 .get(url)
300 .header("Authorization", format!("Bearer {}", creds.access_token))
301 .header("anthropic-beta", USAGE_BETA_HEADER)
302 .header("User-Agent", USAGE_USER_AGENT)
305 .header("Content-Type", "application/json")
306 .send()
307 .await?;
308
309 let status = resp.status();
310 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
311
312 if status.is_success() {
313 let _: UsageResponse = serde_json::from_slice(&bytes)
316 .map_err(|e| AppError::Schema(format!("usage response unparseable: {e}")))?;
317 Ok(bytes.to_vec())
318 } else {
319 let body = String::from_utf8_lossy(&bytes).into_owned();
320 let msg =
321 oauth::parse_error_body(&body).unwrap_or_else(|| body.chars().take(200).collect());
322 Err(AppError::Http {
323 status: status.as_u16(),
324 body: msg,
325 })
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use std::io::Write;
333 use tempfile::{NamedTempFile, TempDir};
334
335 fn future_creds() -> NamedTempFile {
336 let mut f = NamedTempFile::new().unwrap();
337 let expires_ms = (Utc::now().timestamp_millis()) + 3_600_000;
339 let s = format!(
340 r#"{{"claudeAiOauth":{{
341 "accessToken":"AT","refreshToken":"RT",
342 "expiresAt": {expires_ms},
343 "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
344 }}}}"#
345 );
346 f.write_all(s.as_bytes()).unwrap();
347 f.flush().unwrap();
348 f
349 }
350
351 fn expired_creds_no_refresh() -> NamedTempFile {
354 let mut f = NamedTempFile::new().unwrap();
355 let expires_ms = (Utc::now().timestamp_millis()) - 3_600_000; let s = format!(
357 r#"{{"claudeAiOauth":{{
358 "accessToken":"AT","refreshToken":"",
359 "expiresAt": {expires_ms},
360 "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
361 }}}}"#
362 );
363 f.write_all(s.as_bytes()).unwrap();
364 f.flush().unwrap();
365 f
366 }
367
368 fn cache_fixture() -> (TempDir, Cache) {
369 let td = TempDir::new().unwrap();
370 let cache = Cache::at(td.path().join("anthropic"));
371 cache.ensure_dir().unwrap();
372 (td, cache)
373 }
374
375 #[tokio::test]
376 async fn desktop_refresh_waits_for_the_account_switch_lock() {
377 let tmp = TempDir::new().unwrap();
378 let lock_path = tmp.path().join(".account-switch.lock");
379 let held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(1)).unwrap();
380 let desktop = crate::anthropic::desktop_creds::source_for(
381 &tmp.path().join("config.json"),
382 &tmp.path().join("profile"),
383 false,
384 [0; 16],
385 )
386 .with_coordination_lock(lock_path);
387 let target = creds::CredsTarget::Desktop(desktop);
388
389 let waiter = tokio::spawn(async move {
390 acquire_credential_lock(&target, Duration::from_secs(2))
391 .await
392 .unwrap()
393 .is_some()
394 });
395 tokio::time::sleep(Duration::from_millis(50)).await;
396 assert!(!waiter.is_finished(), "refresh bypassed the switch lock");
397
398 drop(held);
399 assert!(waiter.await.unwrap());
400 }
401
402 #[tokio::test]
403 async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown() {
404 let mut server = mockito::Server::new_async().await;
408 server
409 .mock("GET", "/api/oauth/usage")
410 .with_status(200)
411 .with_body(r#"{"five_hour":{"utilization":42},"seven_day":{"utilization":15}}"#)
412 .create_async()
413 .await;
414
415 let (_td, cache) = cache_fixture();
416 cache.write_payload(b"{ truncated").unwrap();
417
418 let creds = future_creds();
419 let client = reqwest::Client::new();
420 let endpoints = Endpoints {
421 usage: format!("{}/api/oauth/usage", server.url()),
422 token: format!("{}/token", server.url()),
423 };
424 let outcome = fetch_snapshot(
426 &client,
427 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
428 &cache,
429 &endpoints,
430 Duration::from_secs(3600),
431 )
432 .await
433 .unwrap();
434 assert_eq!(outcome.snapshot.session.utilization_pct, 42);
435 assert_ne!(outcome.snapshot.plan, "Unknown");
436 assert!(!outcome.stale);
437 }
438
439 #[tokio::test]
440 async fn fresh_cache_skips_network() {
441 let (_td, cache) = cache_fixture();
442 cache
443 .write_payload(
444 br#"{"five_hour":{"utilization":42,"resets_at":"2026-05-23T17:30:00Z"},
445 "seven_day":{"utilization":15,"resets_at":"2026-05-30T12:00:00Z"}}"#,
446 )
447 .unwrap();
448
449 let creds = future_creds();
450 let client = reqwest::Client::new();
451 let endpoints = Endpoints {
452 usage: "http://localhost:1/should-not-be-called".into(),
453 token: "http://localhost:1/should-not-be-called".into(),
454 };
455 let outcome = fetch_snapshot(
456 &client,
457 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
458 &cache,
459 &endpoints,
460 Duration::from_secs(60),
461 )
462 .await
463 .unwrap();
464 assert_eq!(outcome.snapshot.session.utilization_pct, 42);
465 assert!(!outcome.stale);
466 }
467
468 #[tokio::test]
469 async fn live_fetch_writes_cache_and_returns_snapshot() {
470 let mut server = mockito::Server::new_async().await;
471 let m = server
472 .mock("GET", "/api/oauth/usage")
473 .with_status(200)
474 .with_body(
475 r#"{"five_hour":{"utilization":50,"resets_at":"2026-05-23T17:30:00Z"},
476 "seven_day":{"utilization":25,"resets_at":"2026-05-30T12:00:00Z"}}"#,
477 )
478 .create_async()
479 .await;
480
481 let (_td, cache) = cache_fixture();
482 let creds = future_creds();
483 let client = reqwest::Client::new();
484 let endpoints = Endpoints {
485 usage: format!("{}/api/oauth/usage", server.url()),
486 token: format!("{}/v1/oauth/token", server.url()),
487 };
488 let outcome = fetch_snapshot(
489 &client,
490 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
491 &cache,
492 &endpoints,
493 Duration::from_secs(0),
494 )
495 .await
496 .unwrap();
497 assert_eq!(outcome.snapshot.session.utilization_pct, 50);
498 assert!(!outcome.stale);
499 m.assert_async().await;
500 assert!(cache.maybe_payload().unwrap().is_some());
502 }
503
504 #[tokio::test]
505 async fn http_429_falls_back_to_stale_cache() {
506 let mut server = mockito::Server::new_async().await;
507 server
508 .mock("GET", "/api/oauth/usage")
509 .with_status(429)
510 .with_body(r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#)
511 .create_async()
512 .await;
513
514 let (_td, cache) = cache_fixture();
515 cache
516 .write_payload(
517 br#"{"five_hour":{"utilization":12,"resets_at":"2026-05-23T17:30:00Z"},
518 "seven_day":{"utilization":5,"resets_at":"2026-05-30T12:00:00Z"}}"#,
519 )
520 .unwrap();
521 let creds = future_creds();
523 let client = reqwest::Client::new();
524 let endpoints = Endpoints {
525 usage: format!("{}/api/oauth/usage", server.url()),
526 token: format!("{}/v1/oauth/token", server.url()),
527 };
528 let outcome = fetch_snapshot(
529 &client,
530 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
531 &cache,
532 &endpoints,
533 Duration::from_secs(0),
534 )
535 .await
536 .unwrap();
537 assert!(outcome.stale);
538 assert_eq!(outcome.snapshot.session.utilization_pct, 12);
539 assert_eq!(outcome.last_error.as_ref().map(|(c, _)| *c), Some(429));
540 assert_eq!(
541 outcome.last_error.as_ref().map(|(_, m)| m.as_str()),
542 Some("slow down")
543 );
544 }
545
546 #[tokio::test]
547 async fn empty_refresh_token_skips_refresh_and_fetches_usage() {
548 let mut server = mockito::Server::new_async().await;
551 let refresh = server
552 .mock("POST", "/v1/oauth/token")
553 .with_status(400)
554 .with_body(
555 r#"{"error":{"type":"invalid_request_error","message":"Invalid request format"}}"#,
556 )
557 .expect(0)
558 .create_async()
559 .await;
560 let usage = server
561 .mock("GET", "/api/oauth/usage")
562 .match_header("authorization", "Bearer AT")
563 .match_header("user-agent", USAGE_USER_AGENT)
564 .match_header("anthropic-beta", USAGE_BETA_HEADER)
565 .with_status(200)
566 .with_body(
567 r#"{"five_hour":{"utilization":61,"resets_at":"2026-06-25T17:30:00Z"},
568 "seven_day":{"utilization":31,"resets_at":"2026-06-26T12:00:00Z"}}"#,
569 )
570 .create_async()
571 .await;
572
573 let (_td, cache) = cache_fixture();
574 cache
575 .write_payload(
576 br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
577 "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
578 )
579 .unwrap();
580
581 let creds = expired_creds_no_refresh();
582 let client = reqwest::Client::new();
583 let endpoints = Endpoints {
584 usage: format!("{}/api/oauth/usage", server.url()),
585 token: format!("{}/v1/oauth/token", server.url()),
586 };
587 let outcome = fetch_snapshot(
588 &client,
589 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
590 &cache,
591 &endpoints,
592 Duration::from_secs(0),
593 )
594 .await
595 .unwrap();
596
597 assert!(!outcome.stale);
598 assert_eq!(outcome.snapshot.session.utilization_pct, 61);
599 assert!(
600 outcome.last_error.is_none(),
601 "empty-refresh path must not poison .last_error, got {:?}",
602 outcome.last_error
603 );
604 refresh.assert_async().await; usage.assert_async().await; }
607
608 #[tokio::test]
609 async fn empty_refresh_token_clears_old_last_error_on_transient_fallback() {
610 let mut server = mockito::Server::new_async().await;
611 let refresh = server
612 .mock("POST", "/v1/oauth/token")
613 .expect(0)
614 .create_async()
615 .await;
616
617 let (_td, cache) = cache_fixture();
618 cache
619 .write_payload(
620 br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
621 "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
622 )
623 .unwrap();
624 cache.write_last_error(400, "Invalid request format");
625
626 let creds = expired_creds_no_refresh();
627 let client = reqwest::Client::builder()
628 .timeout(Duration::from_millis(200))
629 .build()
630 .unwrap();
631 let endpoints = Endpoints {
632 usage: "http://127.0.0.1:1/api/oauth/usage".into(),
633 token: format!("{}/v1/oauth/token", server.url()),
634 };
635 let outcome = fetch_snapshot(
636 &client,
637 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
638 &cache,
639 &endpoints,
640 Duration::from_secs(0),
641 )
642 .await
643 .unwrap();
644
645 assert!(outcome.stale);
646 assert_eq!(outcome.snapshot.session.utilization_pct, 17);
647 assert!(outcome.last_error.is_none());
648 assert!(cache.read_last_error().is_none());
649 refresh.assert_async().await;
650 }
651
652 #[tokio::test]
653 async fn no_cache_and_no_network_returns_error() {
654 let (_td, cache) = cache_fixture();
656 let creds = future_creds();
657 let client = reqwest::Client::builder()
658 .timeout(Duration::from_millis(200))
659 .build()
660 .unwrap();
661 let endpoints = Endpoints {
662 usage: "http://127.0.0.1:1/api/oauth/usage".into(),
663 token: "http://127.0.0.1:1/v1/oauth/token".into(),
664 };
665 let err = fetch_snapshot(
666 &client,
667 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
668 &cache,
669 &endpoints,
670 Duration::from_secs(0),
671 )
672 .await
673 .unwrap_err();
674 assert!(err.is_transient(), "expected transient error, got {err:?}");
675 }
676}