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