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, 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";
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_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
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    // Corrupt fresh cache falls through to a live fetch rather than returning
77    // an all-zero snapshot labelled "Unknown".
78    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    // Maybe refresh.
85    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        // No refresh token to refresh with — the Claude Code client owns token
90        // rotation (trusted-device flow) and leaves `refreshToken` empty. Don't
91        // POST an empty grant (the token endpoint answers 400 "Invalid request
92        // format" and we'd cache a zeroed snapshot). Also clear any stale
93        // token-endpoint error from older builds, then continue with the current
94        // access token: only the real usage request decides whether to fall
95        // back to cache.
96        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                // A rotated refresh token exists *only* in memory until it is
111                // persisted. If the server rotated it and the write-back fails,
112                // the old token on disk is already spent: the next run cannot
113                // refresh and the user is silently logged out. That is a hard
114                // failure, not a best-effort detail.
115                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                // When only the access token changed, a failed write loses
122                // nothing — the next run just refreshes again — so carry on.
123                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    // Fetch usage.
152    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            // Reuse cache silently; no last_error write.
175            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        // These four headers are exactly what the endpoint accepts — the
265        // `User-Agent` is load-bearing (without it the endpoint 429s hard).
266        .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        // Validate it's a usage shape — keep claudebar's "must have five_hour"
276        // sanity check (claudebar:385).
277        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        // Expires 1 hour from now → no refresh needed in tests.
300        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    /// Expired access token AND an empty `refreshToken` — the trusted-device
314    /// shape recent Claude Code builds leave in the shared credential blob.
315    fn expired_creds_no_refresh() -> NamedTempFile {
316        let mut f = NamedTempFile::new().unwrap();
317        let expires_ms = (Utc::now().timestamp_millis()) - 3_600_000; // 1h ago
318        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        // `reuse_cache` used to swallow a parse failure into an all-zero
340        // snapshot labelled "Unknown" and serve it for the rest of the TTL —
341        // a fabricated reading presented as current.
342        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        // A long TTL: the payload IS fresh, it is simply unusable.
360        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        // Cache should now exist.
436        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        // Force the cache to be considered stale by setting TTL = 0.
457        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        // Expired token, empty refresh token. `.expect(0)` is the assertion: an
484        // empty grant must never be POSTed (it would 400 and poison the cache).
485        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; // refresh endpoint was never called
540        usage.assert_async().await; // usage endpoint was still called
541    }
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        // Point at a closed port so we get a transport error.
590        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}