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