Skip to main content

ai_usagebar/openai/
fetch.rs

1//! Orchestrate: read ~/.codex/auth.json → maybe refresh → fetch usage → cache.
2//!
3//! Mirrors `anthropic::fetch::fetch_snapshot` but for the Codex OAuth flow.
4
5use std::path::Path;
6use std::time::Duration;
7
8use chrono::Utc;
9
10use crate::cache::{Cache, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::OpenAiSnapshot;
13
14use super::creds::{self, Tokens};
15use super::oauth;
16use super::types::UsageResponse;
17
18pub const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
19const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
20const REFRESH_TIMEOUT: Duration = Duration::from_secs(25);
21const LOCK_TIMEOUT: Duration = Duration::from_secs(45);
22
23#[derive(Debug, Clone)]
24pub struct Endpoints {
25    pub usage: String,
26    pub token: String,
27}
28
29impl Default for Endpoints {
30    fn default() -> Self {
31        Self {
32            usage: USAGE_URL.into(),
33            token: oauth::TOKEN_URL.into(),
34        }
35    }
36}
37
38/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
39/// specialised to its snapshot.
40pub type FetchOutcome = crate::outcome::Outcome<OpenAiSnapshot>;
41
42pub async fn fetch_snapshot(
43    client: &reqwest::Client,
44    creds_path: &Path,
45    cache: &Cache,
46    endpoints: &Endpoints,
47    cache_ttl: Duration,
48) -> Result<FetchOutcome> {
49    cache.ensure_dir()?;
50    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
51
52    let mut auth = creds::read_from(creds_path)?;
53    let plan_hint = auth.tokens.plan_type_from_id_token();
54
55    // Corrupt fresh cache falls through to a live fetch rather than returning
56    // an all-zero snapshot.
57    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
58        && let Ok(outcome) = reuse(bytes, cache, false, plan_hint.as_deref())
59    {
60        return Ok(outcome);
61    }
62
63    // Maybe refresh — Codex CLI doesn't always populate expires_at, so we use
64    // the id_token's exp claim.
65    let now = Utc::now().timestamp();
66    if oauth::needs_refresh(auth.tokens.expires_at_secs(), now) {
67        match tokio::time::timeout(
68            REFRESH_TIMEOUT,
69            oauth::refresh(client, &endpoints.token, &auth.tokens.refresh_token),
70        )
71        .await
72        {
73            Ok(Ok(rr)) => {
74                auth.tokens.access_token = rr.access_token;
75                // A rotated refresh token exists only in memory until it is
76                // persisted; losing it silently logs the user out on the next
77                // run. See the matching comment in `anthropic::fetch`.
78                let rotated = rr.refresh_token.is_some();
79                if let Some(rt) = rr.refresh_token {
80                    auth.tokens.refresh_token = rt;
81                }
82                if let Some(id) = rr.id_token {
83                    auth.tokens.id_token = id;
84                }
85                // Expiry is normally read from the id_token's exp claim, so a
86                // refresh that returns no new id_token would leave the old
87                // (expired) claim in place and make every later run refresh
88                // again. Record the response's own `expires_in` as the explicit
89                // `expires_at` so the expiry is known either way.
90                if let Some(secs) = rr.expires_in
91                    && let Some(dt) = chrono::DateTime::from_timestamp(now + secs as i64, 0)
92                {
93                    auth.tokens.expires_at = Some(dt.to_rfc3339());
94                }
95                if let Err(e) = creds::write_back(creds_path, &auth)
96                    && rotated
97                {
98                    let msg = format!(
99                        "refreshed token could not be saved ({e}); the rotated \
100                         refresh token is lost — re-run `codex login`"
101                    );
102                    cache.write_last_error(0, &msg);
103                    return handle_auth_failure(cache, plan_hint.as_deref(), false);
104                }
105            }
106            Ok(Err(AppError::Http { status, body })) => {
107                cache.write_last_error(status, &body);
108                return handle_auth_failure(cache, plan_hint.as_deref(), false);
109            }
110            Ok(Err(e)) if e.is_transient() => {
111                return handle_auth_failure(cache, plan_hint.as_deref(), true);
112            }
113            Ok(Err(e)) => {
114                cache.write_last_error(0, &e.to_string());
115                return handle_auth_failure(cache, plan_hint.as_deref(), false);
116            }
117            Err(_) => return handle_auth_failure(cache, plan_hint.as_deref(), true),
118        }
119    }
120
121    match tokio::time::timeout(
122        HTTP_TIMEOUT,
123        fetch_usage(client, &endpoints.usage, &auth.tokens),
124    )
125    .await
126    {
127        Ok(Ok(response)) => {
128            // Cache what we parse, not what arrived. The raw body carries the
129            // account's `user_id`, `account_id` and `email`, none of which any
130            // renderer reads — writing the parsed response is an allowlist by
131            // construction, so a field OpenAI adds later cannot quietly start
132            // living on disk. Same rule Command Code follows.
133            cache.write_payload(&serde_json::to_vec(&response)?)?;
134            let snap = response.into_snapshot(plan_hint.as_deref())?;
135            Ok(crate::outcome::Outcome::fresh(snap))
136        }
137        Ok(Err(AppError::Http { status, body })) => {
138            cache.mark_stale();
139            let last_error = Some(cache.write_last_error(status, &body));
140            fallback(
141                cache,
142                plan_hint.as_deref(),
143                last_error,
144                AppError::Http { status, body },
145            )
146        }
147        Ok(Err(e)) if e.is_transient() => fallback_silent(cache, plan_hint.as_deref(), e),
148        Ok(Err(e)) => {
149            cache.mark_stale();
150            let last_error = Some(cache.write_last_error(0, &e.to_string()));
151            fallback(cache, plan_hint.as_deref(), last_error, e)
152        }
153        Err(_) => fallback_silent(
154            cache,
155            plan_hint.as_deref(),
156            AppError::Transport("openai: usage request timed out".into()),
157        ),
158    }
159}
160
161fn reuse(
162    bytes: Vec<u8>,
163    cache: &Cache,
164    stale: bool,
165    plan_hint: Option<&str>,
166) -> Result<FetchOutcome> {
167    let snap = parse_payload(&bytes, plan_hint)?;
168    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
169}
170
171fn fallback(
172    cache: &Cache,
173    plan_hint: Option<&str>,
174    last_error: Option<(u16, String)>,
175    original: AppError,
176) -> Result<FetchOutcome> {
177    crate::outcome::fallback(cache, last_error, original, |bytes| {
178        parse_payload(bytes, plan_hint)
179    })
180}
181
182fn fallback_silent(
183    cache: &Cache,
184    plan_hint: Option<&str>,
185    original: AppError,
186) -> Result<FetchOutcome> {
187    crate::outcome::fallback(cache, None, original, |bytes| {
188        parse_payload(bytes, plan_hint)
189    })
190}
191
192/// The one place a *synthesized* error beats the original: the refresh failed,
193/// and "run `codex login` to re-auth" tells the user what to do about it,
194/// which the underlying OAuth error does not.
195fn handle_auth_failure(
196    cache: &Cache,
197    plan_hint: Option<&str>,
198    transient: bool,
199) -> Result<FetchOutcome> {
200    let original = if transient {
201        AppError::Transport("openai: no cache and refresh failed transiently".into())
202    } else {
203        AppError::Credentials("openai: token refresh failed; run `codex login` to re-auth".into())
204    };
205    crate::outcome::fallback(cache, None, original, |bytes| {
206        parse_payload(bytes, plan_hint)
207    })
208}
209
210fn parse_payload(bytes: &[u8], plan_hint: Option<&str>) -> Result<OpenAiSnapshot> {
211    parse_response(bytes)?.into_snapshot(plan_hint)
212}
213
214/// The wire response, before it becomes a snapshot. Split out so the live path
215/// can cache the parsed form rather than the raw body.
216fn parse_response(bytes: &[u8]) -> Result<UsageResponse> {
217    Ok(serde_json::from_slice(bytes)?)
218}
219
220fn authorized(client: &reqwest::Client, url: String, t: &Tokens) -> reqwest::RequestBuilder {
221    let mut req = client
222        .get(url)
223        .header("Authorization", format!("Bearer {}", t.access_token))
224        .header("User-Agent", "codex-cli");
225    if let Some(aid) = t.account_id.as_deref() {
226        req = req.header("ChatGPT-Account-Id", aid);
227    }
228    req
229}
230
231/// Cache the usage payload, grafting on the reset-credit expiries from the
232/// second endpoint when they exist. The graft is a field insert into the
233/// original JSON: re-serializing our typed `UsageResponse` would drop every
234/// unknown key the API still sends (`user_id`, tomorrow's new window, …),
235/// and a cache holding only the usage bytes would keep the count while the
236/// deadline beside it vanished for the rest of the TTL. The inserted
237/// `credits` array is our typed projection — status + expiry, never the
238/// redemption `id`.
239async fn fetch_usage(client: &reqwest::Client, url: &str, t: &Tokens) -> Result<UsageResponse> {
240    let resp = authorized(client, url.to_string(), t).send().await?;
241    let status = resp.status();
242    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
243
244    if !status.is_success() {
245        let body: String = String::from_utf8_lossy(&bytes).chars().take(200).collect();
246        return Err(AppError::Http {
247            status: status.as_u16(),
248            body,
249        });
250    }
251    let mut parsed: UsageResponse = serde_json::from_slice(&bytes)
252        .map_err(|e| AppError::Schema(format!("openai usage response: {e}")))?;
253    parsed.rate_limit_reset_credits = enrich_reset_credits(client, url, t, &parsed).await;
254    // Reject drift here, while the caller can still fall back to a good cache.
255    parsed.clone().into_snapshot(None)?;
256    Ok(parsed)
257}
258
259/// The usage endpoint reports how many banked resets exist but not when they
260/// expire; a second call carries the per-credit detail. That call is strictly
261/// additive: its failure leaves the count exactly as the usage endpoint
262/// reported it, because a count with no deadline is still true, and it is not
263/// worth failing a whole refresh over the deadline alone. The count itself
264/// always stays the usage endpoint's — the two responses can disagree across a
265/// redemption, and the one that also carries the quota figures is the one the
266/// rest of the snapshot is consistent with.
267async fn enrich_reset_credits(
268    client: &reqwest::Client,
269    usage_url: &str,
270    t: &Tokens,
271    parsed: &UsageResponse,
272) -> Option<super::types::ResetCreditsBlock> {
273    let mut block = parsed.rate_limit_reset_credits.clone()?;
274    if block.available_count == 0 {
275        return Some(block);
276    }
277    if let Ok(details) = fetch_reset_credits(client, usage_url, t).await {
278        block.credits = details.credits;
279    }
280    Some(block)
281}
282
283async fn fetch_reset_credits(
284    client: &reqwest::Client,
285    usage_url: &str,
286    t: &Tokens,
287) -> Result<super::types::ResetCreditsBlock> {
288    let base = usage_url.strip_suffix("/usage").unwrap_or(usage_url);
289    let resp = authorized(client, format!("{base}/rate-limit-reset-credits"), t)
290        .send()
291        .await?;
292    let status = resp.status();
293    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
294    if !status.is_success() {
295        // The body is discarded rather than reported: this call is optional,
296        // its failure never reaches the user, and it would only carry an
297        // account-identifying error into a log.
298        return Err(AppError::Http {
299            status: status.as_u16(),
300            body: String::new(),
301        });
302    }
303    serde_json::from_slice(&bytes)
304        .map_err(|_| AppError::Schema("openai reset credits response is invalid".into()))
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use base64::Engine;
311    use std::io::Write;
312    use tempfile::{NamedTempFile, TempDir};
313
314    fn fake_jwt(claims: serde_json::Value) -> String {
315        let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
316            .encode(br#"{"alg":"none","typ":"JWT"}"#);
317        let p =
318            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
319        format!("{h}.{p}.sig")
320    }
321
322    fn future_creds() -> NamedTempFile {
323        // exp 1h in the future.
324        let exp = Utc::now().timestamp() + 3600;
325        let jwt = fake_jwt(serde_json::json!({
326            "exp": exp,
327            "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
328        }));
329        let body = format!(
330            r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}",
331                "account_id":"acc"}}}}"#
332        );
333        let mut f = NamedTempFile::new().unwrap();
334        f.write_all(body.as_bytes()).unwrap();
335        f.flush().unwrap();
336        f
337    }
338
339    fn cache_fixture() -> (TempDir, Cache) {
340        let td = TempDir::new().unwrap();
341        let c = Cache::at(td.path().join("openai"));
342        c.ensure_dir().unwrap();
343        (td, c)
344    }
345
346    #[tokio::test]
347    async fn live_200_returns_snapshot_with_plan_from_id_token() {
348        let mut server = mockito::Server::new_async().await;
349        server
350            .mock("GET", "/backend-api/wham/usage")
351            .with_status(200)
352            .with_body(
353                r#"{"plan_type":"plus","rate_limit":{
354                "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_at":1779597324},
355                "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":1780184124}
356            }}"#,
357            )
358            .create_async()
359            .await;
360        let (_td, cache) = cache_fixture();
361        let creds = future_creds();
362        let client = reqwest::Client::new();
363        let endpoints = Endpoints {
364            usage: format!("{}/backend-api/wham/usage", server.url()),
365            token: format!("{}/oauth/token", server.url()),
366        };
367        let out = fetch_snapshot(
368            &client,
369            creds.path(),
370            &cache,
371            &endpoints,
372            Duration::from_secs(0),
373        )
374        .await
375        .unwrap();
376        assert_eq!(out.snapshot.plan, "ChatGPT Plus");
377        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 1);
378        assert!(!out.stale);
379    }
380
381    #[tokio::test]
382    async fn weekly_only_primary_returns_weekly_snapshot() {
383        let mut server = mockito::Server::new_async().await;
384        server
385            .mock("GET", "/backend-api/wham/usage")
386            .with_status(200)
387            .with_body(
388                r#"{"plan_type":"prolite","rate_limit":{
389                "primary_window":{"used_percent":66,"limit_window_seconds":604800,"reset_at":1785261834},
390                "secondary_window":null
391            }}"#,
392            )
393            .create_async()
394            .await;
395        let (_td, cache) = cache_fixture();
396        let creds = future_creds();
397        let endpoints = Endpoints {
398            usage: format!("{}/backend-api/wham/usage", server.url()),
399            token: format!("{}/oauth/token", server.url()),
400        };
401        let out = fetch_snapshot(
402            &reqwest::Client::new(),
403            creds.path(),
404            &cache,
405            &endpoints,
406            Duration::from_secs(0),
407        )
408        .await
409        .unwrap();
410        assert!(out.snapshot.session.is_none());
411        assert_eq!(out.snapshot.weekly.unwrap().utilization_pct, 66);
412    }
413
414    #[tokio::test]
415    async fn corrupt_fresh_cache_refetches_instead_of_showing_an_empty_snapshot() {
416        // `reuse` used to swallow a parse failure into `empty(plan_hint)` and
417        // serve that all-zero snapshot for the rest of the TTL.
418        let mut server = mockito::Server::new_async().await;
419        server
420            .mock("GET", "/backend-api/wham/usage")
421            .with_status(200)
422            .with_body(
423                r#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":37,"limit_window_seconds":18000}}}"#,
424            )
425            .create_async()
426            .await;
427
428        let (_td, cache) = cache_fixture();
429        cache.write_payload(b"{ truncated").unwrap();
430
431        let creds = future_creds();
432        let client = reqwest::Client::new();
433        let endpoints = Endpoints {
434            usage: format!("{}/backend-api/wham/usage", server.url()),
435            token: format!("{}/oauth/token", server.url()),
436        };
437        // A long TTL: the payload IS fresh, it is simply unusable.
438        let out = fetch_snapshot(
439            &client,
440            creds.path(),
441            &cache,
442            &endpoints,
443            Duration::from_secs(3600),
444        )
445        .await
446        .unwrap();
447        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 37);
448        assert!(!out.stale);
449    }
450
451    /// The expiry lives behind a second endpoint. It has to reach the cache
452    /// with the usage figures, or the deadline disappears for the rest of the
453    /// TTL while the count beside it stays on screen.
454    #[tokio::test]
455    async fn banked_reset_expiries_are_fetched_and_cached_with_the_usage_figures() {
456        let mut server = mockito::Server::new_async().await;
457        let usage = server
458            .mock("GET", "/backend-api/wham/usage")
459            .with_body(
460                r#"{"plan_type":"plus","future_field":true,"rate_limit":{
461                    "primary_window":{"used_percent":81,"limit_window_seconds":18000,"reset_at":1786536977}},
462                    "rate_limit_reset_credits":{"available_count":2}}"#,
463            )
464            .create_async()
465            .await;
466        let details = server
467            .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
468            .with_body(
469                r#"{"available_count":2,"credits":[
470                    {"id":"c1","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-17T00:00:00Z"},
471                    {"id":"c2","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-08-01T00:00:00Z"}]}"#,
472            )
473            .create_async()
474            .await;
475
476        let (_td, cache) = cache_fixture();
477        let creds = future_creds();
478        let endpoints = Endpoints {
479            usage: format!("{}/backend-api/wham/usage", server.url()),
480            token: format!("{}/oauth/token", server.url()),
481        };
482        let out = fetch_snapshot(
483            &reqwest::Client::new(),
484            creds.path(),
485            &cache,
486            &endpoints,
487            Duration::from_secs(0),
488        )
489        .await
490        .unwrap();
491        usage.assert_async().await;
492        details.assert_async().await;
493        assert_eq!(out.snapshot.reset_credits.available, 2);
494        assert_eq!(
495            out.snapshot.reset_credits.next_expiry(),
496            Some("2026-07-17T00:00:00Z".parse().unwrap())
497        );
498
499        // The redemption id is what spends a credit; it must not be written to
500        // disk just because it shared a response with the expiry.
501        let cached = std::fs::read_to_string(cache.payload_path()).unwrap();
502        assert!(!cached.contains("\"c1\""), "{cached}");
503        // The cache holds the parsed response, so it holds only what a
504        // renderer reads. An unknown field is dropped rather than kept — the
505        // cache is a short-lived copy of something refetchable, and keeping
506        // the whole body is how the account's identity ended up on disk.
507        assert!(
508            !cached.contains("future_field"),
509            "the cache must not carry fields nothing parses: {cached}"
510        );
511        let reused = parse_payload(cached.as_bytes(), None).unwrap();
512        assert_eq!(reused.reset_credits, out.snapshot.reset_credits);
513    }
514
515    /// The response carries the account's identity — `user_id`, `account_id`
516    /// and `email` — and no renderer reads any of it. Caching the raw body put
517    /// all three on disk for the life of the TTL. Caching the *parsed*
518    /// response is an allowlist by construction: a field OpenAI adds later
519    /// cannot start living there without someone adding it to the type first.
520    #[tokio::test]
521    async fn the_cache_holds_no_account_identity() {
522        let mut server = mockito::Server::new_async().await;
523        let usage = server
524            .mock("GET", "/backend-api/wham/usage")
525            .with_body(
526                r#"{"plan_type":"pro",
527                    "user_id":"user_abc123",
528                    "account_id":"acct_abc123",
529                    "email":"person@example.test",
530                    "rate_limit":{"primary_window":{"used_percent":5,
531                                  "limit_window_seconds":604800}}}"#,
532            )
533            .create_async()
534            .await;
535
536        let (_td, cache) = cache_fixture();
537        let creds = future_creds();
538        let endpoints = Endpoints {
539            usage: format!("{}/backend-api/wham/usage", server.url()),
540            token: format!("{}/oauth/token", server.url()),
541        };
542        let out = fetch_snapshot(
543            &reqwest::Client::new(),
544            creds.path(),
545            &cache,
546            &endpoints,
547            Duration::from_secs(0),
548        )
549        .await
550        .unwrap();
551        usage.assert_async().await;
552
553        // The figures still arrive.
554        assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 5);
555
556        let cached = std::fs::read_to_string(cache.payload_path()).unwrap();
557        for identity in ["user_abc123", "acct_abc123", "person@example.test"] {
558            assert!(
559                !cached.contains(identity),
560                "{identity} reached the cache: {cached}"
561            );
562        }
563        for key in ["user_id", "account_id", "email"] {
564            assert!(!cached.contains(key), "{key} reached the cache: {cached}");
565        }
566    }
567
568    /// The detail call is an extra. When it fails, the count the usage
569    /// endpoint reported is still true and still worth showing — refusing the
570    /// whole refresh over a missing deadline would cost the quota figures too.
571    #[tokio::test]
572    async fn a_failed_detail_call_keeps_the_count_from_the_usage_response() {
573        let mut server = mockito::Server::new_async().await;
574        server
575            .mock("GET", "/backend-api/wham/usage")
576            .with_body(
577                r#"{"plan_type":"plus","rate_limit":{
578                    "primary_window":{"used_percent":10,"limit_window_seconds":18000}},
579                    "rate_limit_reset_credits":{"available_count":1}}"#,
580            )
581            .create_async()
582            .await;
583        let details = server
584            .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
585            .with_status(404)
586            .create_async()
587            .await;
588
589        let (_td, cache) = cache_fixture();
590        let creds = future_creds();
591        let endpoints = Endpoints {
592            usage: format!("{}/backend-api/wham/usage", server.url()),
593            token: format!("{}/oauth/token", server.url()),
594        };
595        let out = fetch_snapshot(
596            &reqwest::Client::new(),
597            creds.path(),
598            &cache,
599            &endpoints,
600            Duration::from_secs(0),
601        )
602        .await
603        .unwrap();
604        details.assert_async().await;
605        assert!(!out.stale);
606        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
607        assert_eq!(out.snapshot.reset_credits.available, 1);
608        assert!(out.snapshot.reset_credits.credits.is_empty());
609    }
610
611    /// With nothing banked there is nothing to detail. Asking anyway would
612    /// double every refresh's request count for every account that has none.
613    #[tokio::test]
614    async fn no_banked_resets_means_no_second_request() {
615        let mut server = mockito::Server::new_async().await;
616        server
617            .mock("GET", "/backend-api/wham/usage")
618            .with_body(
619                r#"{"plan_type":"plus","rate_limit":{
620                    "primary_window":{"used_percent":10,"limit_window_seconds":18000}},
621                    "rate_limit_reset_credits":{"available_count":0}}"#,
622            )
623            .create_async()
624            .await;
625        let details = server
626            .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
627            .expect(0)
628            .create_async()
629            .await;
630
631        let (_td, cache) = cache_fixture();
632        let creds = future_creds();
633        let endpoints = Endpoints {
634            usage: format!("{}/backend-api/wham/usage", server.url()),
635            token: format!("{}/oauth/token", server.url()),
636        };
637        let out = fetch_snapshot(
638            &reqwest::Client::new(),
639            creds.path(),
640            &cache,
641            &endpoints,
642            Duration::from_secs(0),
643        )
644        .await
645        .unwrap();
646        details.assert_async().await;
647        assert!(out.snapshot.reset_credits.is_empty());
648    }
649
650    #[tokio::test]
651    async fn http_500_falls_back_to_cache_when_present() {
652        let mut server = mockito::Server::new_async().await;
653        server
654            .mock("GET", "/backend-api/wham/usage")
655            .with_status(500)
656            .with_body(r#"{"error":{"message":"upstream"}}"#)
657            .create_async()
658            .await;
659        let (_td, cache) = cache_fixture();
660        cache
661            .write_payload(
662                br#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000}}}"#,
663            )
664            .unwrap();
665        let creds = future_creds();
666        let client = reqwest::Client::new();
667        let endpoints = Endpoints {
668            usage: format!("{}/backend-api/wham/usage", server.url()),
669            token: format!("{}/oauth/token", server.url()),
670        };
671        let out = fetch_snapshot(
672            &client,
673            creds.path(),
674            &cache,
675            &endpoints,
676            Duration::from_secs(0),
677        )
678        .await
679        .unwrap();
680        assert!(out.stale);
681        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 50);
682        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
683    }
684}