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            cache.write_last_error(status, &body);
180            fallback_to_cache(cache, plan_label, Some((status, body)))
181        }
182        Ok(Err(e)) if e.is_transient() => {
183            // Reuse cache silently; no last_error write.
184            fallback_to_cache_silent(cache, plan_label)
185        }
186        Ok(Err(e)) => {
187            cache.mark_stale();
188            cache.write_last_error(0, &e.to_string());
189            fallback_to_cache(cache, plan_label, Some((0, e.to_string())))
190        }
191        Err(_elapsed) => fallback_to_cache_silent(cache, plan_label),
192    }
193}
194
195async fn acquire_credential_lock(
196    target: &creds::CredsTarget,
197    timeout: Duration,
198) -> Result<Option<LockGuard>> {
199    let creds::CredsTarget::Desktop(desktop) = target else {
200        return Ok(None);
201    };
202    let Some(path) = desktop.coordination_lock() else {
203        return Ok(None);
204    };
205    acquire_lock_async(path, timeout).await.map(Some)
206}
207
208fn reuse_cache(
209    bytes: Vec<u8>,
210    plan_label: String,
211    cache: &Cache,
212    stale: bool,
213) -> Result<FetchOutcome> {
214    let snap = parse_payload(&bytes, plan_label)?;
215    Ok(FetchOutcome {
216        snapshot: snap,
217        stale,
218        last_error: cache.read_last_error(),
219        cache_age: cache.payload_age(),
220    })
221}
222
223fn fallback_to_cache(
224    cache: &Cache,
225    plan_label: String,
226    last_error: Option<(u16, String)>,
227) -> Result<FetchOutcome> {
228    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
229        return Err(AppError::Other("no usable cache".into()));
230    };
231    let snap = parse_payload(&bytes, plan_label)?;
232    Ok(FetchOutcome {
233        snapshot: snap,
234        stale: true,
235        last_error,
236        cache_age: cache.payload_age(),
237    })
238}
239
240fn fallback_to_cache_silent(cache: &Cache, plan_label: String) -> Result<FetchOutcome> {
241    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
242        return Err(AppError::Transport(
243            "no cache and network unreachable".into(),
244        ));
245    };
246    let snap = parse_payload(&bytes, plan_label)?;
247    Ok(FetchOutcome {
248        snapshot: snap,
249        stale: true,
250        last_error: cache.read_last_error(),
251        cache_age: cache.payload_age(),
252    })
253}
254
255fn handle_auth_failure(cache: &Cache, plan_label: String, transient: bool) -> Result<FetchOutcome> {
256    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
257        return if transient {
258            Err(AppError::Transport(
259                "no cache and refresh failed transiently".into(),
260            ))
261        } else {
262            Err(AppError::Credentials(
263                "token refresh failed; run `claude` to re-auth".into(),
264            ))
265        };
266    };
267    let snap = parse_payload(&bytes, plan_label)?;
268    Ok(FetchOutcome {
269        snapshot: snap,
270        stale: true,
271        last_error: cache.read_last_error(),
272        cache_age: cache.payload_age(),
273    })
274}
275
276fn parse_payload(bytes: &[u8], plan_label: String) -> Result<AnthropicSnapshot> {
277    let resp: UsageResponse = serde_json::from_slice(bytes)?;
278    Ok(resp.into_snapshot(plan_label))
279}
280
281async fn fetch_usage(client: &reqwest::Client, url: &str, creds: &OauthCreds) -> Result<Vec<u8>> {
282    let resp = client
283        .get(url)
284        .header("Authorization", format!("Bearer {}", creds.access_token))
285        .header("anthropic-beta", USAGE_BETA_HEADER)
286        // These four headers are exactly what the endpoint accepts — the
287        // `User-Agent` is load-bearing (without it the endpoint 429s hard).
288        .header("User-Agent", USAGE_USER_AGENT)
289        .header("Content-Type", "application/json")
290        .send()
291        .await?;
292
293    let status = resp.status();
294    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
295
296    if status.is_success() {
297        // Validate it's a usage shape — keep claudebar's "must have five_hour"
298        // sanity check (claudebar:385).
299        let _: UsageResponse = serde_json::from_slice(&bytes)
300            .map_err(|e| AppError::Schema(format!("usage response unparseable: {e}")))?;
301        Ok(bytes.to_vec())
302    } else {
303        let body = String::from_utf8_lossy(&bytes).into_owned();
304        let msg =
305            oauth::parse_error_body(&body).unwrap_or_else(|| body.chars().take(200).collect());
306        Err(AppError::Http {
307            status: status.as_u16(),
308            body: msg,
309        })
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use std::io::Write;
317    use tempfile::{NamedTempFile, TempDir};
318
319    fn future_creds() -> NamedTempFile {
320        let mut f = NamedTempFile::new().unwrap();
321        // Expires 1 hour from now → no refresh needed in tests.
322        let expires_ms = (Utc::now().timestamp_millis()) + 3_600_000;
323        let s = format!(
324            r#"{{"claudeAiOauth":{{
325                "accessToken":"AT","refreshToken":"RT",
326                "expiresAt": {expires_ms},
327                "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
328            }}}}"#
329        );
330        f.write_all(s.as_bytes()).unwrap();
331        f.flush().unwrap();
332        f
333    }
334
335    /// Expired access token AND an empty `refreshToken` — the trusted-device
336    /// shape recent Claude Code builds leave in the shared credential blob.
337    fn expired_creds_no_refresh() -> NamedTempFile {
338        let mut f = NamedTempFile::new().unwrap();
339        let expires_ms = (Utc::now().timestamp_millis()) - 3_600_000; // 1h ago
340        let s = format!(
341            r#"{{"claudeAiOauth":{{
342                "accessToken":"AT","refreshToken":"",
343                "expiresAt": {expires_ms},
344                "subscriptionType":"max","rateLimitTier":"default_claude_max_5x"
345            }}}}"#
346        );
347        f.write_all(s.as_bytes()).unwrap();
348        f.flush().unwrap();
349        f
350    }
351
352    fn cache_fixture() -> (TempDir, Cache) {
353        let td = TempDir::new().unwrap();
354        let cache = Cache::at(td.path().join("anthropic"));
355        cache.ensure_dir().unwrap();
356        (td, cache)
357    }
358
359    #[tokio::test]
360    async fn desktop_refresh_waits_for_the_account_switch_lock() {
361        let tmp = TempDir::new().unwrap();
362        let lock_path = tmp.path().join(".account-switch.lock");
363        let held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(1)).unwrap();
364        let desktop = crate::anthropic::desktop_creds::source_for(
365            &tmp.path().join("config.json"),
366            &tmp.path().join("profile"),
367            false,
368            [0; 16],
369        )
370        .with_coordination_lock(lock_path);
371        let target = creds::CredsTarget::Desktop(desktop);
372
373        let waiter = tokio::spawn(async move {
374            acquire_credential_lock(&target, Duration::from_secs(2))
375                .await
376                .unwrap()
377                .is_some()
378        });
379        tokio::time::sleep(Duration::from_millis(50)).await;
380        assert!(!waiter.is_finished(), "refresh bypassed the switch lock");
381
382        drop(held);
383        assert!(waiter.await.unwrap());
384    }
385
386    #[tokio::test]
387    async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown() {
388        // `reuse_cache` used to swallow a parse failure into an all-zero
389        // snapshot labelled "Unknown" and serve it for the rest of the TTL —
390        // a fabricated reading presented as current.
391        let mut server = mockito::Server::new_async().await;
392        server
393            .mock("GET", "/api/oauth/usage")
394            .with_status(200)
395            .with_body(r#"{"five_hour":{"utilization":42},"seven_day":{"utilization":15}}"#)
396            .create_async()
397            .await;
398
399        let (_td, cache) = cache_fixture();
400        cache.write_payload(b"{ truncated").unwrap();
401
402        let creds = future_creds();
403        let client = reqwest::Client::new();
404        let endpoints = Endpoints {
405            usage: format!("{}/api/oauth/usage", server.url()),
406            token: format!("{}/token", server.url()),
407        };
408        // A long TTL: the payload IS fresh, it is simply unusable.
409        let outcome = fetch_snapshot(
410            &client,
411            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
412            &cache,
413            &endpoints,
414            Duration::from_secs(3600),
415        )
416        .await
417        .unwrap();
418        assert_eq!(outcome.snapshot.session.utilization_pct, 42);
419        assert_ne!(outcome.snapshot.plan, "Unknown");
420        assert!(!outcome.stale);
421    }
422
423    #[tokio::test]
424    async fn fresh_cache_skips_network() {
425        let (_td, cache) = cache_fixture();
426        cache
427            .write_payload(
428                br#"{"five_hour":{"utilization":42,"resets_at":"2026-05-23T17:30:00Z"},
429                     "seven_day":{"utilization":15,"resets_at":"2026-05-30T12:00:00Z"}}"#,
430            )
431            .unwrap();
432
433        let creds = future_creds();
434        let client = reqwest::Client::new();
435        let endpoints = Endpoints {
436            usage: "http://localhost:1/should-not-be-called".into(),
437            token: "http://localhost:1/should-not-be-called".into(),
438        };
439        let outcome = fetch_snapshot(
440            &client,
441            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
442            &cache,
443            &endpoints,
444            Duration::from_secs(60),
445        )
446        .await
447        .unwrap();
448        assert_eq!(outcome.snapshot.session.utilization_pct, 42);
449        assert!(!outcome.stale);
450    }
451
452    #[tokio::test]
453    async fn live_fetch_writes_cache_and_returns_snapshot() {
454        let mut server = mockito::Server::new_async().await;
455        let m = server
456            .mock("GET", "/api/oauth/usage")
457            .with_status(200)
458            .with_body(
459                r#"{"five_hour":{"utilization":50,"resets_at":"2026-05-23T17:30:00Z"},
460                    "seven_day":{"utilization":25,"resets_at":"2026-05-30T12:00:00Z"}}"#,
461            )
462            .create_async()
463            .await;
464
465        let (_td, cache) = cache_fixture();
466        let creds = future_creds();
467        let client = reqwest::Client::new();
468        let endpoints = Endpoints {
469            usage: format!("{}/api/oauth/usage", server.url()),
470            token: format!("{}/v1/oauth/token", server.url()),
471        };
472        let outcome = fetch_snapshot(
473            &client,
474            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
475            &cache,
476            &endpoints,
477            Duration::from_secs(0),
478        )
479        .await
480        .unwrap();
481        assert_eq!(outcome.snapshot.session.utilization_pct, 50);
482        assert!(!outcome.stale);
483        m.assert_async().await;
484        // Cache should now exist.
485        assert!(cache.maybe_payload().unwrap().is_some());
486    }
487
488    #[tokio::test]
489    async fn http_429_falls_back_to_stale_cache() {
490        let mut server = mockito::Server::new_async().await;
491        server
492            .mock("GET", "/api/oauth/usage")
493            .with_status(429)
494            .with_body(r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#)
495            .create_async()
496            .await;
497
498        let (_td, cache) = cache_fixture();
499        cache
500            .write_payload(
501                br#"{"five_hour":{"utilization":12,"resets_at":"2026-05-23T17:30:00Z"},
502                     "seven_day":{"utilization":5,"resets_at":"2026-05-30T12:00:00Z"}}"#,
503            )
504            .unwrap();
505        // Force the cache to be considered stale by setting TTL = 0.
506        let creds = future_creds();
507        let client = reqwest::Client::new();
508        let endpoints = Endpoints {
509            usage: format!("{}/api/oauth/usage", server.url()),
510            token: format!("{}/v1/oauth/token", server.url()),
511        };
512        let outcome = fetch_snapshot(
513            &client,
514            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
515            &cache,
516            &endpoints,
517            Duration::from_secs(0),
518        )
519        .await
520        .unwrap();
521        assert!(outcome.stale);
522        assert_eq!(outcome.snapshot.session.utilization_pct, 12);
523        assert_eq!(outcome.last_error.as_ref().map(|(c, _)| *c), Some(429));
524        assert_eq!(
525            outcome.last_error.as_ref().map(|(_, m)| m.as_str()),
526            Some("slow down")
527        );
528    }
529
530    #[tokio::test]
531    async fn empty_refresh_token_skips_refresh_and_fetches_usage() {
532        // Expired token, empty refresh token. `.expect(0)` is the assertion: an
533        // empty grant must never be POSTed (it would 400 and poison the cache).
534        let mut server = mockito::Server::new_async().await;
535        let refresh = server
536            .mock("POST", "/v1/oauth/token")
537            .with_status(400)
538            .with_body(
539                r#"{"error":{"type":"invalid_request_error","message":"Invalid request format"}}"#,
540            )
541            .expect(0)
542            .create_async()
543            .await;
544        let usage = server
545            .mock("GET", "/api/oauth/usage")
546            .match_header("authorization", "Bearer AT")
547            .match_header("user-agent", USAGE_USER_AGENT)
548            .match_header("anthropic-beta", USAGE_BETA_HEADER)
549            .with_status(200)
550            .with_body(
551                r#"{"five_hour":{"utilization":61,"resets_at":"2026-06-25T17:30:00Z"},
552                    "seven_day":{"utilization":31,"resets_at":"2026-06-26T12:00:00Z"}}"#,
553            )
554            .create_async()
555            .await;
556
557        let (_td, cache) = cache_fixture();
558        cache
559            .write_payload(
560                br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
561                     "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
562            )
563            .unwrap();
564
565        let creds = expired_creds_no_refresh();
566        let client = reqwest::Client::new();
567        let endpoints = Endpoints {
568            usage: format!("{}/api/oauth/usage", server.url()),
569            token: format!("{}/v1/oauth/token", server.url()),
570        };
571        let outcome = fetch_snapshot(
572            &client,
573            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
574            &cache,
575            &endpoints,
576            Duration::from_secs(0),
577        )
578        .await
579        .unwrap();
580
581        assert!(!outcome.stale);
582        assert_eq!(outcome.snapshot.session.utilization_pct, 61);
583        assert!(
584            outcome.last_error.is_none(),
585            "empty-refresh path must not poison .last_error, got {:?}",
586            outcome.last_error
587        );
588        refresh.assert_async().await; // refresh endpoint was never called
589        usage.assert_async().await; // usage endpoint was still called
590    }
591
592    #[tokio::test]
593    async fn empty_refresh_token_clears_old_last_error_on_transient_fallback() {
594        let mut server = mockito::Server::new_async().await;
595        let refresh = server
596            .mock("POST", "/v1/oauth/token")
597            .expect(0)
598            .create_async()
599            .await;
600
601        let (_td, cache) = cache_fixture();
602        cache
603            .write_payload(
604                br#"{"five_hour":{"utilization":17,"resets_at":"2026-06-25T17:30:00Z"},
605                     "seven_day":{"utilization":77,"resets_at":"2026-06-26T12:00:00Z"}}"#,
606            )
607            .unwrap();
608        cache.write_last_error(400, "Invalid request format");
609
610        let creds = expired_creds_no_refresh();
611        let client = reqwest::Client::builder()
612            .timeout(Duration::from_millis(200))
613            .build()
614            .unwrap();
615        let endpoints = Endpoints {
616            usage: "http://127.0.0.1:1/api/oauth/usage".into(),
617            token: format!("{}/v1/oauth/token", server.url()),
618        };
619        let outcome = fetch_snapshot(
620            &client,
621            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
622            &cache,
623            &endpoints,
624            Duration::from_secs(0),
625        )
626        .await
627        .unwrap();
628
629        assert!(outcome.stale);
630        assert_eq!(outcome.snapshot.session.utilization_pct, 17);
631        assert!(outcome.last_error.is_none());
632        assert!(cache.read_last_error().is_none());
633        refresh.assert_async().await;
634    }
635
636    #[tokio::test]
637    async fn no_cache_and_no_network_returns_error() {
638        // Point at a closed port so we get a transport error.
639        let (_td, cache) = cache_fixture();
640        let creds = future_creds();
641        let client = reqwest::Client::builder()
642            .timeout(Duration::from_millis(200))
643            .build()
644            .unwrap();
645        let endpoints = Endpoints {
646            usage: "http://127.0.0.1:1/api/oauth/usage".into(),
647            token: "http://127.0.0.1:1/v1/oauth/token".into(),
648        };
649        let err = fetch_snapshot(
650            &client,
651            &creds::CredsTarget::Explicit(creds.path().to_path_buf()),
652            &cache,
653            &endpoints,
654            Duration::from_secs(0),
655        )
656        .await
657        .unwrap_err();
658        assert!(err.is_transient(), "expected transient error, got {err:?}");
659    }
660}