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