Skip to main content

ai_usagebar/anthropic/
fetch.rs

1//! Stitches together: read creds → maybe-refresh → GET usage → cache result.
2//!
3//! Mirrors claudebar:402-491 — the lock + refresh + fetch state machine.
4
5use 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";
19/// The usage endpoint rate-limits hard unless the request carries a Claude Code
20/// `User-Agent`. The exact patch version isn't validated, so a stable recent
21/// `claude-code/<version>` (what the official client sends) is fine.
22pub 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/// Endpoints (parameterized for tests).
28#[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/// What we ultimately hand back to the renderer.
44#[derive(Debug, Clone)]
45pub struct FetchOutcome {
46    pub snapshot: AnthropicSnapshot,
47    /// True if this snapshot came from the on-disk cache because the live
48    /// fetch failed — the widget shows a `⏸` indicator in this case.
49    pub stale: bool,
50    /// Last fetch error, if any — drives the `.last_error` tooltip line.
51    pub last_error: Option<(u16, String)>,
52    /// When the on-disk cache was written. Drives the "Updated HH:MM" line.
53    pub cache_age: Option<Duration>,
54}
55
56/// High-level entry point. Reads creds, refreshes if needed, fetches usage,
57/// writes back the cache, and returns the snapshot — falling back to cache on
58/// failure. All under a flock so multi-monitor Waybar instances coexist.
59pub 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    // Fast path: cache is fresh, no work needed. We still need creds for the
70    // plan label though, so read them either way. `resolve` also reports where
71    // the creds actually came from (file vs macOS Keychain) so the refresh
72    // write-back follows the same source instead of forking a stale copy.
73    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    // Maybe refresh.
81    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        // No refresh token to refresh with — the Claude Code client owns token
86        // rotation (trusted-device flow) and leaves `refreshToken` empty. Don't
87        // POST an empty grant (the token endpoint answers 400 "Invalid request
88        // format" and we'd cache a zeroed snapshot). Also clear any stale
89        // token-endpoint error from older builds, then continue with the current
90        // access token: only the real usage request decides whether to fall
91        // back to cache.
92        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                // Best-effort persist; the refresh worked, so callers should
112                // still see fresh data even if writing the creds back failed.
113                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    // Fetch usage.
133    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            // Reuse cache silently; no last_error write.
156            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        // These four headers are exactly what the endpoint accepts — the
246        // `User-Agent` is load-bearing (without it the endpoint 429s hard).
247        .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        // Validate it's a usage shape — keep claudebar's "must have five_hour"
257        // sanity check (claudebar:385).
258        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        // Expires 1 hour from now → no refresh needed in tests.
281        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    /// Expired access token AND an empty `refreshToken` — the trusted-device
295    /// shape recent Claude Code builds leave in the shared credential blob.
296    fn expired_creds_no_refresh() -> NamedTempFile {
297        let mut f = NamedTempFile::new().unwrap();
298        let expires_ms = (Utc::now().timestamp_millis()) - 3_600_000; // 1h ago
299        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        // Cache should now exist.
380        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        // Force the cache to be considered stale by setting TTL = 0.
401        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        // Expired token, empty refresh token. `.expect(0)` is the assertion: an
428        // empty grant must never be POSTed (it would 400 and poison the cache).
429        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; // refresh endpoint was never called
484        usage.assert_async().await; // usage endpoint was still called
485    }
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        // Point at a closed port so we get a transport error.
534        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}