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, LockGuard, 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    // Desktop snapshots and account switching can mutate the same rotating
69    // credential. Hold their shared lock from source resolution through any
70    // refresh write-back; the live config source is read-only but also uses the
71    // lock so it cannot be read halfway through our own switch transaction.
72    let credential_lock = acquire_credential_lock(creds_target, LOCK_TIMEOUT).await?;
73
74    // Fast path: cache is fresh, no work needed. We still need creds for the
75    // plan label though, so read them either way. `resolve` also reports where
76    // the creds actually came from (file vs macOS Keychain) so the refresh
77    // write-back follows the same source instead of forking a stale copy.
78    let (mut creds, creds_source) = creds::resolve(creds_target)?;
79    let plan_label = creds.claude_ai_oauth.plan_label();
80
81    // Corrupt fresh cache falls through to a live fetch rather than returning
82    // an all-zero snapshot labelled "Unknown".
83    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
84        && let Ok(outcome) = reuse_cache(bytes, plan_label.clone(), cache, false)
85    {
86        return Ok(outcome);
87    }
88
89    // Maybe refresh.
90    let now = Utc::now().timestamp();
91    let stale_token = oauth::needs_refresh(creds.claude_ai_oauth.expires_at_secs(), now);
92    let have_refresh = oauth::can_refresh(&creds.claude_ai_oauth.refresh_token);
93    if stale_token && !have_refresh {
94        // No refresh token to refresh with — the Claude Code client owns token
95        // rotation (trusted-device flow) and leaves `refreshToken` empty. Don't
96        // POST an empty grant (the token endpoint answers 400 "Invalid request
97        // format" and we'd cache a zeroed snapshot). Also clear any stale
98        // token-endpoint error from older builds, then continue with the current
99        // access token: only the real usage request decides whether to fall
100        // back to cache.
101        cache.clear_last_error();
102    } else if stale_token {
103        match tokio::time::timeout(
104            REFRESH_TIMEOUT,
105            oauth::refresh(
106                client,
107                &endpoints.token,
108                &creds.claude_ai_oauth.refresh_token,
109            ),
110        )
111        .await
112        {
113            Ok(Ok(rr)) => {
114                creds.claude_ai_oauth.access_token = rr.access_token;
115                // A rotated refresh token exists *only* in memory until it is
116                // persisted. If the server rotated it and the write-back fails,
117                // the old token on disk is already spent: the next run cannot
118                // refresh and the user is silently logged out. That is a hard
119                // failure, not a best-effort detail.
120                let rotated = rr.refresh_token.is_some();
121                if let Some(new_rt) = rr.refresh_token {
122                    creds.claude_ai_oauth.refresh_token = new_rt;
123                }
124                creds.claude_ai_oauth.expires_at_ms =
125                    Utc::now().timestamp_millis() + (rr.expires_in as i64) * 1000;
126                // When only the access token changed, a failed write loses
127                // nothing — the next run just refreshes again — so carry on.
128                if let Err(e) = creds::write_back_to(&creds_source, &creds.claude_ai_oauth)
129                    && rotated
130                {
131                    let msg = format!(
132                        "refreshed token could not be saved ({e}); the rotated \
133                         refresh token is lost — re-run `claude` to log in again"
134                    );
135                    cache.write_last_error(0, &msg);
136                    return handle_auth_failure(cache, plan_label, false);
137                }
138            }
139            Ok(Err(AppError::Http { status, body })) => {
140                cache.write_last_error(status, &body);
141                return handle_auth_failure(cache, plan_label, false);
142            }
143            Ok(Err(e)) if e.is_transient() => {
144                return handle_auth_failure(cache, plan_label, true);
145            }
146            Ok(Err(e)) => {
147                cache.write_last_error(0, &e.to_string());
148                return handle_auth_failure(cache, plan_label, false);
149            }
150            Err(_elapsed) => {
151                return handle_auth_failure(cache, plan_label, true);
152            }
153        }
154    }
155
156    // Usage fetches do not mutate credentials and should not make an account
157    // switch wait on the network once refresh/write-back is complete.
158    drop(credential_lock);
159
160    // Fetch usage.
161    match tokio::time::timeout(
162        HTTP_TIMEOUT,
163        fetch_usage(client, &endpoints.usage, &creds.claude_ai_oauth),
164    )
165    .await
166    {
167        Ok(Ok(bytes)) => {
168            cache.write_payload(&bytes)?;
169            let snap = parse_payload(&bytes, plan_label.clone())?;
170            Ok(FetchOutcome {
171                snapshot: snap,
172                stale: false,
173                last_error: None,
174                cache_age: Some(Duration::ZERO),
175            })
176        }
177        Ok(Err(AppError::Http { status, body })) => {
178            cache.mark_stale();
179            let last_error = Some(cache.write_last_error(status, &body));
180            fallback_to_cache(
181                cache,
182                plan_label,
183                last_error,
184                AppError::Http { status, body },
185            )
186        }
187        Ok(Err(e)) if e.is_transient() => {
188            // Reuse cache silently; no last_error write.
189            fallback_to_cache_silent(cache, plan_label, e)
190        }
191        Ok(Err(e)) => {
192            cache.mark_stale();
193            let last_error = Some(cache.write_last_error(0, &e.to_string()));
194            fallback_to_cache(cache, plan_label, last_error, e)
195        }
196        Err(_elapsed) => fallback_to_cache_silent(
197            cache,
198            plan_label,
199            AppError::Transport("usage request timed out".into()),
200        ),
201    }
202}
203
204async fn acquire_credential_lock(
205    target: &creds::CredsTarget,
206    timeout: Duration,
207) -> Result<Option<LockGuard>> {
208    let creds::CredsTarget::Desktop(desktop) = target else {
209        return Ok(None);
210    };
211    let Some(path) = desktop.coordination_lock() else {
212        return Ok(None);
213    };
214    acquire_lock_async(path, timeout).await.map(Some)
215}
216
217fn reuse_cache(
218    bytes: Vec<u8>,
219    plan_label: String,
220    cache: &Cache,
221    stale: bool,
222) -> Result<FetchOutcome> {
223    let snap = parse_payload(&bytes, plan_label)?;
224    Ok(FetchOutcome {
225        snapshot: snap,
226        stale,
227        last_error: cache.read_last_error(),
228        cache_age: cache.payload_age(),
229    })
230}
231
232/// On failure we show the last good figure with the error alongside it. With
233/// nothing usable cached there is nothing to show, so the **original** error is
234/// returned rather than a generic "no usable cache" that hides what went wrong
235/// — a cold cache and an expired key would otherwise look identical.
236fn fallback_to_cache(
237    cache: &Cache,
238    plan_label: String,
239    last_error: Option<(u16, String)>,
240    original: AppError,
241) -> Result<FetchOutcome> {
242    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
243        return Err(original);
244    };
245    let snap = parse_payload(&bytes, plan_label)?;
246    Ok(FetchOutcome {
247        snapshot: snap,
248        stale: true,
249        last_error,
250        cache_age: cache.payload_age(),
251    })
252}
253
254fn fallback_to_cache_silent(
255    cache: &Cache,
256    plan_label: String,
257    original: AppError,
258) -> Result<FetchOutcome> {
259    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
260        return Err(original);
261    };
262    let snap = parse_payload(&bytes, plan_label)?;
263    Ok(FetchOutcome {
264        snapshot: snap,
265        stale: true,
266        last_error: cache.read_last_error(),
267        cache_age: cache.payload_age(),
268    })
269}
270
271fn handle_auth_failure(cache: &Cache, plan_label: String, transient: bool) -> Result<FetchOutcome> {
272    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
273        return if transient {
274            Err(AppError::Transport(
275                "no cache and refresh failed transiently".into(),
276            ))
277        } else {
278            Err(AppError::Credentials(
279                "token refresh failed; run `claude` to re-auth".into(),
280            ))
281        };
282    };
283    let snap = parse_payload(&bytes, plan_label)?;
284    Ok(FetchOutcome {
285        snapshot: snap,
286        stale: true,
287        last_error: cache.read_last_error(),
288        cache_age: cache.payload_age(),
289    })
290}
291
292fn parse_payload(bytes: &[u8], plan_label: String) -> Result<AnthropicSnapshot> {
293    let resp: UsageResponse = serde_json::from_slice(bytes)?;
294    Ok(resp.into_snapshot(plan_label))
295}
296
297async fn fetch_usage(client: &reqwest::Client, url: &str, creds: &OauthCreds) -> Result<Vec<u8>> {
298    let resp = client
299        .get(url)
300        .header("Authorization", format!("Bearer {}", creds.access_token))
301        .header("anthropic-beta", USAGE_BETA_HEADER)
302        // These four headers are exactly what the endpoint accepts — the
303        // `User-Agent` is load-bearing (without it the endpoint 429s hard).
304        .header("User-Agent", USAGE_USER_AGENT)
305        .header("Content-Type", "application/json")
306        .send()
307        .await?;
308
309    let status = resp.status();
310    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
311
312    if status.is_success() {
313        // Validate it's a usage shape — keep claudebar's "must have five_hour"
314        // sanity check (claudebar:385).
315        let _: UsageResponse = serde_json::from_slice(&bytes)
316            .map_err(|e| AppError::Schema(format!("usage response unparseable: {e}")))?;
317        Ok(bytes.to_vec())
318    } else {
319        let body = String::from_utf8_lossy(&bytes).into_owned();
320        let msg =
321            oauth::parse_error_body(&body).unwrap_or_else(|| body.chars().take(200).collect());
322        Err(AppError::Http {
323            status: status.as_u16(),
324            body: msg,
325        })
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use std::io::Write;
333    use tempfile::{NamedTempFile, TempDir};
334
335    fn future_creds() -> NamedTempFile {
336        let mut f = NamedTempFile::new().unwrap();
337        // Expires 1 hour from now → no refresh needed in tests.
338        let expires_ms = (Utc::now().timestamp_millis()) + 3_600_000;
339        let s = format!(
340            r#"{{"claudeAiOauth":{{
341                "accessToken":"AT","refreshToken":"RT",
342                "expiresAt": {expires_ms},
343                "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
344            }}}}"#
345        );
346        f.write_all(s.as_bytes()).unwrap();
347        f.flush().unwrap();
348        f
349    }
350
351    /// Expired access token AND an empty `refreshToken` — the trusted-device
352    /// shape recent Claude Code builds leave in the shared credential blob.
353    fn expired_creds_no_refresh() -> NamedTempFile {
354        let mut f = NamedTempFile::new().unwrap();
355        let expires_ms = (Utc::now().timestamp_millis()) - 3_600_000; // 1h ago
356        let s = format!(
357            r#"{{"claudeAiOauth":{{
358                "accessToken":"AT","refreshToken":"",
359                "expiresAt": {expires_ms},
360                "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
361            }}}}"#
362        );
363        f.write_all(s.as_bytes()).unwrap();
364        f.flush().unwrap();
365        f
366    }
367
368    fn cache_fixture() -> (TempDir, Cache) {
369        let td = TempDir::new().unwrap();
370        let cache = Cache::at(td.path().join("anthropic"));
371        cache.ensure_dir().unwrap();
372        (td, cache)
373    }
374
375    #[tokio::test]
376    async fn desktop_refresh_waits_for_the_account_switch_lock() {
377        let tmp = TempDir::new().unwrap();
378        let lock_path = tmp.path().join(".account-switch.lock");
379        let held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(1)).unwrap();
380        let desktop = crate::anthropic::desktop_creds::source_for(
381            &tmp.path().join("config.json"),
382            &tmp.path().join("profile"),
383            false,
384            [0; 16],
385        )
386        .with_coordination_lock(lock_path);
387        let target = creds::CredsTarget::Desktop(desktop);
388
389        let waiter = tokio::spawn(async move {
390            acquire_credential_lock(&target, Duration::from_secs(2))
391                .await
392                .unwrap()
393                .is_some()
394        });
395        tokio::time::sleep(Duration::from_millis(50)).await;
396        assert!(!waiter.is_finished(), "refresh bypassed the switch lock");
397
398        drop(held);
399        assert!(waiter.await.unwrap());
400    }
401
402    #[tokio::test]
403    async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown() {
404        // `reuse_cache` used to swallow a parse failure into an all-zero
405        // snapshot labelled "Unknown" and serve it for the rest of the TTL —
406        // a fabricated reading presented as current.
407        let mut server = mockito::Server::new_async().await;
408        server
409            .mock("GET", "/api/oauth/usage")
410            .with_status(200)
411            .with_body(r#"{"five_hour":{"utilization":42},"seven_day":{"utilization":15}}"#)
412            .create_async()
413            .await;
414
415        let (_td, cache) = cache_fixture();
416        cache.write_payload(b"{ truncated").unwrap();
417
418        let creds = future_creds();
419        let client = reqwest::Client::new();
420        let endpoints = Endpoints {
421            usage: format!("{}/api/oauth/usage", server.url()),
422            token: format!("{}/token", server.url()),
423        };
424        // A long TTL: the payload IS fresh, it is simply unusable.
425        let outcome = fetch_snapshot(
426            &client,
427            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
428            &cache,
429            &endpoints,
430            Duration::from_secs(3600),
431        )
432        .await
433        .unwrap();
434        assert_eq!(outcome.snapshot.session.utilization_pct, 42);
435        assert_ne!(outcome.snapshot.plan, "Unknown");
436        assert!(!outcome.stale);
437    }
438
439    #[tokio::test]
440    async fn fresh_cache_skips_network() {
441        let (_td, cache) = cache_fixture();
442        cache
443            .write_payload(
444                br#"{"five_hour":{"utilization":42,"resets_at":"2026-05-23T17:30:00Z"},
445                     "seven_day":{"utilization":15,"resets_at":"2026-05-30T12:00:00Z"}}"#,
446            )
447            .unwrap();
448
449        let creds = future_creds();
450        let client = reqwest::Client::new();
451        let endpoints = Endpoints {
452            usage: "http://localhost:1/should-not-be-called".into(),
453            token: "http://localhost:1/should-not-be-called".into(),
454        };
455        let outcome = fetch_snapshot(
456            &client,
457            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
458            &cache,
459            &endpoints,
460            Duration::from_secs(60),
461        )
462        .await
463        .unwrap();
464        assert_eq!(outcome.snapshot.session.utilization_pct, 42);
465        assert!(!outcome.stale);
466    }
467
468    #[tokio::test]
469    async fn live_fetch_writes_cache_and_returns_snapshot() {
470        let mut server = mockito::Server::new_async().await;
471        let m = server
472            .mock("GET", "/api/oauth/usage")
473            .with_status(200)
474            .with_body(
475                r#"{"five_hour":{"utilization":50,"resets_at":"2026-05-23T17:30:00Z"},
476                    "seven_day":{"utilization":25,"resets_at":"2026-05-30T12:00:00Z"}}"#,
477            )
478            .create_async()
479            .await;
480
481        let (_td, cache) = cache_fixture();
482        let creds = future_creds();
483        let client = reqwest::Client::new();
484        let endpoints = Endpoints {
485            usage: format!("{}/api/oauth/usage", server.url()),
486            token: format!("{}/v1/oauth/token", server.url()),
487        };
488        let outcome = fetch_snapshot(
489            &client,
490            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
491            &cache,
492            &endpoints,
493            Duration::from_secs(0),
494        )
495        .await
496        .unwrap();
497        assert_eq!(outcome.snapshot.session.utilization_pct, 50);
498        assert!(!outcome.stale);
499        m.assert_async().await;
500        // Cache should now exist.
501        assert!(cache.maybe_payload().unwrap().is_some());
502    }
503
504    #[tokio::test]
505    async fn http_429_falls_back_to_stale_cache() {
506        let mut server = mockito::Server::new_async().await;
507        server
508            .mock("GET", "/api/oauth/usage")
509            .with_status(429)
510            .with_body(r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#)
511            .create_async()
512            .await;
513
514        let (_td, cache) = cache_fixture();
515        cache
516            .write_payload(
517                br#"{"five_hour":{"utilization":12,"resets_at":"2026-05-23T17:30:00Z"},
518                     "seven_day":{"utilization":5,"resets_at":"2026-05-30T12:00:00Z"}}"#,
519            )
520            .unwrap();
521        // Force the cache to be considered stale by setting TTL = 0.
522        let creds = future_creds();
523        let client = reqwest::Client::new();
524        let endpoints = Endpoints {
525            usage: format!("{}/api/oauth/usage", server.url()),
526            token: format!("{}/v1/oauth/token", server.url()),
527        };
528        let outcome = fetch_snapshot(
529            &client,
530            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
531            &cache,
532            &endpoints,
533            Duration::from_secs(0),
534        )
535        .await
536        .unwrap();
537        assert!(outcome.stale);
538        assert_eq!(outcome.snapshot.session.utilization_pct, 12);
539        assert_eq!(outcome.last_error.as_ref().map(|(c, _)| *c), Some(429));
540        assert_eq!(
541            outcome.last_error.as_ref().map(|(_, m)| m.as_str()),
542            Some("slow down")
543        );
544    }
545
546    #[tokio::test]
547    async fn empty_refresh_token_skips_refresh_and_fetches_usage() {
548        // Expired token, empty refresh token. `.expect(0)` is the assertion: an
549        // empty grant must never be POSTed (it would 400 and poison the cache).
550        let mut server = mockito::Server::new_async().await;
551        let refresh = server
552            .mock("POST", "/v1/oauth/token")
553            .with_status(400)
554            .with_body(
555                r#"{"error":{"type":"invalid_request_error","message":"Invalid request format"}}"#,
556            )
557            .expect(0)
558            .create_async()
559            .await;
560        let usage = server
561            .mock("GET", "/api/oauth/usage")
562            .match_header("authorization", "Bearer AT")
563            .match_header("user-agent", USAGE_USER_AGENT)
564            .match_header("anthropic-beta", USAGE_BETA_HEADER)
565            .with_status(200)
566            .with_body(
567                r#"{"five_hour":{"utilization":61,"resets_at":"2026-06-25T17:30:00Z"},
568                    "seven_day":{"utilization":31,"resets_at":"2026-06-26T12:00:00Z"}}"#,
569            )
570            .create_async()
571            .await;
572
573        let (_td, cache) = cache_fixture();
574        cache
575            .write_payload(
576                br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
577                     "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
578            )
579            .unwrap();
580
581        let creds = expired_creds_no_refresh();
582        let client = reqwest::Client::new();
583        let endpoints = Endpoints {
584            usage: format!("{}/api/oauth/usage", server.url()),
585            token: format!("{}/v1/oauth/token", server.url()),
586        };
587        let outcome = fetch_snapshot(
588            &client,
589            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
590            &cache,
591            &endpoints,
592            Duration::from_secs(0),
593        )
594        .await
595        .unwrap();
596
597        assert!(!outcome.stale);
598        assert_eq!(outcome.snapshot.session.utilization_pct, 61);
599        assert!(
600            outcome.last_error.is_none(),
601            "empty-refresh path must not poison .last_error, got {:?}",
602            outcome.last_error
603        );
604        refresh.assert_async().await; // refresh endpoint was never called
605        usage.assert_async().await; // usage endpoint was still called
606    }
607
608    #[tokio::test]
609    async fn empty_refresh_token_clears_old_last_error_on_transient_fallback() {
610        let mut server = mockito::Server::new_async().await;
611        let refresh = server
612            .mock("POST", "/v1/oauth/token")
613            .expect(0)
614            .create_async()
615            .await;
616
617        let (_td, cache) = cache_fixture();
618        cache
619            .write_payload(
620                br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
621                     "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
622            )
623            .unwrap();
624        cache.write_last_error(400, "Invalid request format");
625
626        let creds = expired_creds_no_refresh();
627        let client = reqwest::Client::builder()
628            .timeout(Duration::from_millis(200))
629            .build()
630            .unwrap();
631        let endpoints = Endpoints {
632            usage: "http://127.0.0.1:1/api/oauth/usage".into(),
633            token: format!("{}/v1/oauth/token", server.url()),
634        };
635        let outcome = fetch_snapshot(
636            &client,
637            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
638            &cache,
639            &endpoints,
640            Duration::from_secs(0),
641        )
642        .await
643        .unwrap();
644
645        assert!(outcome.stale);
646        assert_eq!(outcome.snapshot.session.utilization_pct, 17);
647        assert!(outcome.last_error.is_none());
648        assert!(cache.read_last_error().is_none());
649        refresh.assert_async().await;
650    }
651
652    #[tokio::test]
653    async fn no_cache_and_no_network_returns_error() {
654        // Point at a closed port so we get a transport error.
655        let (_td, cache) = cache_fixture();
656        let creds = future_creds();
657        let client = reqwest::Client::builder()
658            .timeout(Duration::from_millis(200))
659            .build()
660            .unwrap();
661        let endpoints = Endpoints {
662            usage: "http://127.0.0.1:1/api/oauth/usage".into(),
663            token: "http://127.0.0.1:1/v1/oauth/token".into(),
664        };
665        let err = fetch_snapshot(
666            &client,
667            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
668            &cache,
669            &endpoints,
670            Duration::from_secs(0),
671        )
672        .await
673        .unwrap_err();
674        assert!(err.is_transient(), "expected transient error, got {err:?}");
675    }
676}