1use std::time::Duration;
6
7use chrono::Utc;
8
9use crate::cache::{Cache, 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
69 let (mut creds, creds_source) = creds::resolve(creds_target)?;
74 let plan_label = creds.claude_ai_oauth.plan_label();
75
76 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
79 && let Ok(outcome) = reuse_cache(bytes, plan_label.clone(), cache, false)
80 {
81 return Ok(outcome);
82 }
83
84 let now = Utc::now().timestamp();
86 let stale_token = oauth::needs_refresh(creds.claude_ai_oauth.expires_at_secs(), now);
87 let have_refresh = oauth::can_refresh(&creds.claude_ai_oauth.refresh_token);
88 if stale_token && !have_refresh {
89 cache.clear_last_error();
97 } else if stale_token {
98 match tokio::time::timeout(
99 REFRESH_TIMEOUT,
100 oauth::refresh(
101 client,
102 &endpoints.token,
103 &creds.claude_ai_oauth.refresh_token,
104 ),
105 )
106 .await
107 {
108 Ok(Ok(rr)) => {
109 creds.claude_ai_oauth.access_token = rr.access_token;
110 let rotated = rr.refresh_token.is_some();
116 if let Some(new_rt) = rr.refresh_token {
117 creds.claude_ai_oauth.refresh_token = new_rt;
118 }
119 creds.claude_ai_oauth.expires_at_ms =
120 Utc::now().timestamp_millis() + (rr.expires_in as i64) * 1000;
121 if let Err(e) = creds::write_back_to(&creds_source, &creds.claude_ai_oauth)
124 && rotated
125 {
126 let msg = format!(
127 "refreshed token could not be saved ({e}); the rotated \
128 refresh token is lost — re-run `claude` to log in again"
129 );
130 cache.write_last_error(0, &msg);
131 return handle_auth_failure(cache, plan_label, false);
132 }
133 }
134 Ok(Err(AppError::Http { status, body })) => {
135 cache.write_last_error(status, &body);
136 return handle_auth_failure(cache, plan_label, false);
137 }
138 Ok(Err(e)) if e.is_transient() => {
139 return handle_auth_failure(cache, plan_label, true);
140 }
141 Ok(Err(e)) => {
142 cache.write_last_error(0, &e.to_string());
143 return handle_auth_failure(cache, plan_label, false);
144 }
145 Err(_elapsed) => {
146 return handle_auth_failure(cache, plan_label, true);
147 }
148 }
149 }
150
151 match tokio::time::timeout(
153 HTTP_TIMEOUT,
154 fetch_usage(client, &endpoints.usage, &creds.claude_ai_oauth),
155 )
156 .await
157 {
158 Ok(Ok(bytes)) => {
159 cache.write_payload(&bytes)?;
160 let snap = parse_payload(&bytes, plan_label.clone())?;
161 Ok(FetchOutcome {
162 snapshot: snap,
163 stale: false,
164 last_error: None,
165 cache_age: Some(Duration::ZERO),
166 })
167 }
168 Ok(Err(AppError::Http { status, body })) => {
169 cache.mark_stale();
170 cache.write_last_error(status, &body);
171 fallback_to_cache(cache, plan_label, Some((status, body)))
172 }
173 Ok(Err(e)) if e.is_transient() => {
174 fallback_to_cache_silent(cache, plan_label)
176 }
177 Ok(Err(e)) => {
178 cache.mark_stale();
179 cache.write_last_error(0, &e.to_string());
180 fallback_to_cache(cache, plan_label, Some((0, e.to_string())))
181 }
182 Err(_elapsed) => fallback_to_cache_silent(cache, plan_label),
183 }
184}
185
186fn reuse_cache(
187 bytes: Vec<u8>,
188 plan_label: String,
189 cache: &Cache,
190 stale: bool,
191) -> Result<FetchOutcome> {
192 let snap = parse_payload(&bytes, plan_label)?;
193 Ok(FetchOutcome {
194 snapshot: snap,
195 stale,
196 last_error: cache.read_last_error(),
197 cache_age: cache.payload_age(),
198 })
199}
200
201fn fallback_to_cache(
202 cache: &Cache,
203 plan_label: String,
204 last_error: Option<(u16, String)>,
205) -> Result<FetchOutcome> {
206 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
207 return Err(AppError::Other("no usable cache".into()));
208 };
209 let snap = parse_payload(&bytes, plan_label)?;
210 Ok(FetchOutcome {
211 snapshot: snap,
212 stale: true,
213 last_error,
214 cache_age: cache.payload_age(),
215 })
216}
217
218fn fallback_to_cache_silent(cache: &Cache, plan_label: String) -> Result<FetchOutcome> {
219 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
220 return Err(AppError::Transport(
221 "no cache and network unreachable".into(),
222 ));
223 };
224 let snap = parse_payload(&bytes, plan_label)?;
225 Ok(FetchOutcome {
226 snapshot: snap,
227 stale: true,
228 last_error: cache.read_last_error(),
229 cache_age: cache.payload_age(),
230 })
231}
232
233fn handle_auth_failure(cache: &Cache, plan_label: String, transient: bool) -> Result<FetchOutcome> {
234 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
235 return if transient {
236 Err(AppError::Transport(
237 "no cache and refresh failed transiently".into(),
238 ))
239 } else {
240 Err(AppError::Credentials(
241 "token refresh failed; run `claude` to re-auth".into(),
242 ))
243 };
244 };
245 let snap = parse_payload(&bytes, plan_label)?;
246 Ok(FetchOutcome {
247 snapshot: snap,
248 stale: true,
249 last_error: cache.read_last_error(),
250 cache_age: cache.payload_age(),
251 })
252}
253
254fn parse_payload(bytes: &[u8], plan_label: String) -> Result<AnthropicSnapshot> {
255 let resp: UsageResponse = serde_json::from_slice(bytes)?;
256 Ok(resp.into_snapshot(plan_label))
257}
258
259async fn fetch_usage(client: &reqwest::Client, url: &str, creds: &OauthCreds) -> Result<Vec<u8>> {
260 let resp = client
261 .get(url)
262 .header("Authorization", format!("Bearer {}", creds.access_token))
263 .header("anthropic-beta", USAGE_BETA_HEADER)
264 .header("User-Agent", USAGE_USER_AGENT)
267 .header("Content-Type", "application/json")
268 .send()
269 .await?;
270
271 let status = resp.status();
272 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
273
274 if status.is_success() {
275 let _: UsageResponse = serde_json::from_slice(&bytes)
278 .map_err(|e| AppError::Schema(format!("usage response unparseable: {e}")))?;
279 Ok(bytes.to_vec())
280 } else {
281 let body = String::from_utf8_lossy(&bytes).into_owned();
282 let msg =
283 oauth::parse_error_body(&body).unwrap_or_else(|| body.chars().take(200).collect());
284 Err(AppError::Http {
285 status: status.as_u16(),
286 body: msg,
287 })
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use std::io::Write;
295 use tempfile::{NamedTempFile, TempDir};
296
297 fn future_creds() -> NamedTempFile {
298 let mut f = NamedTempFile::new().unwrap();
299 let expires_ms = (Utc::now().timestamp_millis()) + 3_600_000;
301 let s = format!(
302 r#"{{"claudeAiOauth":{{
303 "accessToken":"AT","refreshToken":"RT",
304 "expiresAt": {expires_ms},
305 "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
306 }}}}"#
307 );
308 f.write_all(s.as_bytes()).unwrap();
309 f.flush().unwrap();
310 f
311 }
312
313 fn expired_creds_no_refresh() -> NamedTempFile {
316 let mut f = NamedTempFile::new().unwrap();
317 let expires_ms = (Utc::now().timestamp_millis()) - 3_600_000; let s = format!(
319 r#"{{"claudeAiOauth":{{
320 "accessToken":"AT","refreshToken":"",
321 "expiresAt": {expires_ms},
322 "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
323 }}}}"#
324 );
325 f.write_all(s.as_bytes()).unwrap();
326 f.flush().unwrap();
327 f
328 }
329
330 fn cache_fixture() -> (TempDir, Cache) {
331 let td = TempDir::new().unwrap();
332 let cache = Cache::at(td.path().join("anthropic"));
333 cache.ensure_dir().unwrap();
334 (td, cache)
335 }
336
337 #[tokio::test]
338 async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown() {
339 let mut server = mockito::Server::new_async().await;
343 server
344 .mock("GET", "/api/oauth/usage")
345 .with_status(200)
346 .with_body(r#"{"five_hour":{"utilization":42},"seven_day":{"utilization":15}}"#)
347 .create_async()
348 .await;
349
350 let (_td, cache) = cache_fixture();
351 cache.write_payload(b"{ truncated").unwrap();
352
353 let creds = future_creds();
354 let client = reqwest::Client::new();
355 let endpoints = Endpoints {
356 usage: format!("{}/api/oauth/usage", server.url()),
357 token: format!("{}/token", server.url()),
358 };
359 let outcome = fetch_snapshot(
361 &client,
362 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
363 &cache,
364 &endpoints,
365 Duration::from_secs(3600),
366 )
367 .await
368 .unwrap();
369 assert_eq!(outcome.snapshot.session.utilization_pct, 42);
370 assert_ne!(outcome.snapshot.plan, "Unknown");
371 assert!(!outcome.stale);
372 }
373
374 #[tokio::test]
375 async fn fresh_cache_skips_network() {
376 let (_td, cache) = cache_fixture();
377 cache
378 .write_payload(
379 br#"{"five_hour":{"utilization":42,"resets_at":"2026-05-23T17:30:00Z"},
380 "seven_day":{"utilization":15,"resets_at":"2026-05-30T12:00:00Z"}}"#,
381 )
382 .unwrap();
383
384 let creds = future_creds();
385 let client = reqwest::Client::new();
386 let endpoints = Endpoints {
387 usage: "http://localhost:1/should-not-be-called".into(),
388 token: "http://localhost:1/should-not-be-called".into(),
389 };
390 let outcome = fetch_snapshot(
391 &client,
392 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
393 &cache,
394 &endpoints,
395 Duration::from_secs(60),
396 )
397 .await
398 .unwrap();
399 assert_eq!(outcome.snapshot.session.utilization_pct, 42);
400 assert!(!outcome.stale);
401 }
402
403 #[tokio::test]
404 async fn live_fetch_writes_cache_and_returns_snapshot() {
405 let mut server = mockito::Server::new_async().await;
406 let m = server
407 .mock("GET", "/api/oauth/usage")
408 .with_status(200)
409 .with_body(
410 r#"{"five_hour":{"utilization":50,"resets_at":"2026-05-23T17:30:00Z"},
411 "seven_day":{"utilization":25,"resets_at":"2026-05-30T12:00:00Z"}}"#,
412 )
413 .create_async()
414 .await;
415
416 let (_td, cache) = cache_fixture();
417 let creds = future_creds();
418 let client = reqwest::Client::new();
419 let endpoints = Endpoints {
420 usage: format!("{}/api/oauth/usage", server.url()),
421 token: format!("{}/v1/oauth/token", server.url()),
422 };
423 let outcome = fetch_snapshot(
424 &client,
425 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
426 &cache,
427 &endpoints,
428 Duration::from_secs(0),
429 )
430 .await
431 .unwrap();
432 assert_eq!(outcome.snapshot.session.utilization_pct, 50);
433 assert!(!outcome.stale);
434 m.assert_async().await;
435 assert!(cache.maybe_payload().unwrap().is_some());
437 }
438
439 #[tokio::test]
440 async fn http_429_falls_back_to_stale_cache() {
441 let mut server = mockito::Server::new_async().await;
442 server
443 .mock("GET", "/api/oauth/usage")
444 .with_status(429)
445 .with_body(r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#)
446 .create_async()
447 .await;
448
449 let (_td, cache) = cache_fixture();
450 cache
451 .write_payload(
452 br#"{"five_hour":{"utilization":12,"resets_at":"2026-05-23T17:30:00Z"},
453 "seven_day":{"utilization":5,"resets_at":"2026-05-30T12:00:00Z"}}"#,
454 )
455 .unwrap();
456 let creds = future_creds();
458 let client = reqwest::Client::new();
459 let endpoints = Endpoints {
460 usage: format!("{}/api/oauth/usage", server.url()),
461 token: format!("{}/v1/oauth/token", server.url()),
462 };
463 let outcome = fetch_snapshot(
464 &client,
465 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
466 &cache,
467 &endpoints,
468 Duration::from_secs(0),
469 )
470 .await
471 .unwrap();
472 assert!(outcome.stale);
473 assert_eq!(outcome.snapshot.session.utilization_pct, 12);
474 assert_eq!(outcome.last_error.as_ref().map(|(c, _)| *c), Some(429));
475 assert_eq!(
476 outcome.last_error.as_ref().map(|(_, m)| m.as_str()),
477 Some("slow down")
478 );
479 }
480
481 #[tokio::test]
482 async fn empty_refresh_token_skips_refresh_and_fetches_usage() {
483 let mut server = mockito::Server::new_async().await;
486 let refresh = server
487 .mock("POST", "/v1/oauth/token")
488 .with_status(400)
489 .with_body(
490 r#"{"error":{"type":"invalid_request_error","message":"Invalid request format"}}"#,
491 )
492 .expect(0)
493 .create_async()
494 .await;
495 let usage = server
496 .mock("GET", "/api/oauth/usage")
497 .match_header("authorization", "Bearer AT")
498 .match_header("user-agent", USAGE_USER_AGENT)
499 .match_header("anthropic-beta", USAGE_BETA_HEADER)
500 .with_status(200)
501 .with_body(
502 r#"{"five_hour":{"utilization":61,"resets_at":"2026-06-25T17:30:00Z"},
503 "seven_day":{"utilization":31,"resets_at":"2026-06-26T12:00:00Z"}}"#,
504 )
505 .create_async()
506 .await;
507
508 let (_td, cache) = cache_fixture();
509 cache
510 .write_payload(
511 br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
512 "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
513 )
514 .unwrap();
515
516 let creds = expired_creds_no_refresh();
517 let client = reqwest::Client::new();
518 let endpoints = Endpoints {
519 usage: format!("{}/api/oauth/usage", server.url()),
520 token: format!("{}/v1/oauth/token", server.url()),
521 };
522 let outcome = fetch_snapshot(
523 &client,
524 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
525 &cache,
526 &endpoints,
527 Duration::from_secs(0),
528 )
529 .await
530 .unwrap();
531
532 assert!(!outcome.stale);
533 assert_eq!(outcome.snapshot.session.utilization_pct, 61);
534 assert!(
535 outcome.last_error.is_none(),
536 "empty-refresh path must not poison .last_error, got {:?}",
537 outcome.last_error
538 );
539 refresh.assert_async().await; usage.assert_async().await; }
542
543 #[tokio::test]
544 async fn empty_refresh_token_clears_old_last_error_on_transient_fallback() {
545 let mut server = mockito::Server::new_async().await;
546 let refresh = server
547 .mock("POST", "/v1/oauth/token")
548 .expect(0)
549 .create_async()
550 .await;
551
552 let (_td, cache) = cache_fixture();
553 cache
554 .write_payload(
555 br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
556 "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
557 )
558 .unwrap();
559 cache.write_last_error(400, "Invalid request format");
560
561 let creds = expired_creds_no_refresh();
562 let client = reqwest::Client::builder()
563 .timeout(Duration::from_millis(200))
564 .build()
565 .unwrap();
566 let endpoints = Endpoints {
567 usage: "http://127.0.0.1:1/api/oauth/usage".into(),
568 token: format!("{}/v1/oauth/token", server.url()),
569 };
570 let outcome = fetch_snapshot(
571 &client,
572 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
573 &cache,
574 &endpoints,
575 Duration::from_secs(0),
576 )
577 .await
578 .unwrap();
579
580 assert!(outcome.stale);
581 assert_eq!(outcome.snapshot.session.utilization_pct, 17);
582 assert!(outcome.last_error.is_none());
583 assert!(cache.read_last_error().is_none());
584 refresh.assert_async().await;
585 }
586
587 #[tokio::test]
588 async fn no_cache_and_no_network_returns_error() {
589 let (_td, cache) = cache_fixture();
591 let creds = future_creds();
592 let client = reqwest::Client::builder()
593 .timeout(Duration::from_millis(200))
594 .build()
595 .unwrap();
596 let endpoints = Endpoints {
597 usage: "http://127.0.0.1:1/api/oauth/usage".into(),
598 token: "http://127.0.0.1:1/v1/oauth/token".into(),
599 };
600 let err = fetch_snapshot(
601 &client,
602 &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
603 &cache,
604 &endpoints,
605 Duration::from_secs(0),
606 )
607 .await
608 .unwrap_err();
609 assert!(err.is_transient(), "expected transient error, got {err:?}");
610 }
611}