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, PathBuf};
6use std::time::Duration;
7
8use chrono::Utc;
9
10use crate::cache::{Cache, LockGuard, 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/// A switch holds the credentials lock for a few file writes only.
23const CREDENTIALS_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
24/// How often a route that moved under the lock is followed. Each move means a
25/// switch landed between two looks, so a second one is already rare.
26const ROUTE_ATTEMPTS: usize = 3;
27
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/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
44/// specialised to its snapshot.
45pub type FetchOutcome = crate::outcome::Outcome<OpenAiSnapshot>;
46
47pub async fn fetch_snapshot(
48    client: &reqwest::Client,
49    creds_path: &Path,
50    cache: &Cache,
51    endpoints: &Endpoints,
52    cache_ttl: Duration,
53) -> Result<FetchOutcome> {
54    let route = || Ok(creds_path.to_path_buf());
55    fetch_snapshot_routed(client, route, cache, endpoints, cache_ttl).await
56}
57
58/// [`fetch_snapshot`] for a login `account switch --codex` can move: `route`
59/// names the file to read, and is asked again once that file's lock is held.
60/// A path chosen before the lock can name the default slot after a switch
61/// installed another account there, and that account's usage would then land
62/// in this one's cache.
63pub async fn fetch_snapshot_routed(
64    client: &reqwest::Client,
65    route: impl Fn() -> Result<PathBuf>,
66    cache: &Cache,
67    endpoints: &Endpoints,
68    cache_ttl: Duration,
69) -> Result<FetchOutcome> {
70    cache.ensure_dir()?;
71    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
72    let (creds_path, _credentials_lock) = lock_route(&route).await?;
73    let creds_path = creds_path.as_path();
74
75    let mut auth = creds::read_from(creds_path)?;
76    let plan_hint = auth.tokens.plan_type_from_id_token();
77
78    // Corrupt fresh cache falls through to a live fetch rather than returning
79    // an all-zero snapshot.
80    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
81        && let Ok(outcome) = reuse(bytes, cache, false, plan_hint.as_deref())
82    {
83        return Ok(outcome);
84    }
85
86    // Maybe refresh — Codex CLI doesn't always populate expires_at, so we use
87    // the id_token's exp claim.
88    let now = Utc::now().timestamp();
89    let stale_token = oauth::needs_refresh(auth.tokens.expires_at_secs(), now);
90    let have_refresh = !auth.tokens.refresh_token.trim().is_empty();
91    if stale_token && !have_refresh {
92        // A signed-out credentials file or a flow without refresh tokens has
93        // an empty `refresh_token`. Don't POST an empty grant (the token
94        // endpoint answers 400 and we would record an auth error). Also clear
95        // any stale token-endpoint error from older builds, then continue with
96        // the current access token: only the real usage request (or the
97        // blank-token check below) decides whether to fall back to cache.
98        cache.clear_last_error();
99    } else if stale_token {
100        match tokio::time::timeout(
101            REFRESH_TIMEOUT,
102            oauth::refresh(client, &endpoints.token, &auth.tokens.refresh_token),
103        )
104        .await
105        {
106            Ok(Ok(rr)) => {
107                auth.tokens.access_token = rr.access_token;
108                // A rotated refresh token exists only in memory until it is
109                // persisted; losing it silently logs the user out on the next
110                // run. See the matching comment in `anthropic::fetch`.
111                let rotated = rr.refresh_token.is_some();
112                if let Some(rt) = rr.refresh_token {
113                    auth.tokens.refresh_token = rt;
114                }
115                if let Some(id) = rr.id_token {
116                    auth.tokens.id_token = id;
117                }
118                // Expiry is normally read from the id_token's exp claim, so a
119                // refresh that returns no new id_token would leave the old
120                // (expired) claim in place and make every later run refresh
121                // again. Record the response's own `expires_in` as the explicit
122                // `expires_at` so the expiry is known either way.
123                if let Some(secs) = rr.expires_in
124                    && let Some(dt) = chrono::DateTime::from_timestamp(now + secs as i64, 0)
125                {
126                    auth.tokens.expires_at = Some(dt.to_rfc3339());
127                }
128                if let Err(e) = creds::write_back(creds_path, &auth)
129                    && rotated
130                {
131                    let msg = format!(
132                        "refreshed token could not be saved ({e}); the rotated \
133                         refresh token is lost — re-run `codex login`"
134                    );
135                    cache.write_last_error(0, &msg);
136                    return handle_auth_failure(cache, plan_hint.as_deref(), false);
137                }
138            }
139            Ok(Err(AppError::Http { status, body })) => {
140                cache.write_last_error(status, &body);
141                return handle_auth_failure(cache, plan_hint.as_deref(), false);
142            }
143            Ok(Err(e)) if e.is_transient() => {
144                return handle_auth_failure(cache, plan_hint.as_deref(), true);
145            }
146            Ok(Err(e)) => {
147                cache.write_last_error(0, &e.to_string());
148                return handle_auth_failure(cache, plan_hint.as_deref(), false);
149            }
150            Err(_) => return handle_auth_failure(cache, plan_hint.as_deref(), true),
151        }
152    }
153
154    // A blank access token is a signed-out credentials file, never a rate
155    // limit: sending `Authorization: Bearer ` would earn a 401/429, and the
156    // 429 variant would arm the backoff so the card stays wrong after
157    // re-login. Checked after any refresh so a blank-access/live-refresh
158    // shape self-repairs first.
159    if auth.tokens.access_token.trim().is_empty() {
160        return fallback_silent(
161            cache,
162            plan_hint.as_deref(),
163            AppError::Credentials("no access token; run `codex login` to log in".into()),
164        );
165    }
166
167    match tokio::time::timeout(
168        HTTP_TIMEOUT,
169        fetch_usage(client, &endpoints.usage, &auth.tokens),
170    )
171    .await
172    {
173        Ok(Ok(response)) => {
174            // Cache what we parse, not what arrived. The raw body carries the
175            // account's `user_id`, `account_id` and `email`, none of which any
176            // renderer reads — writing the parsed response is an allowlist by
177            // construction, so a field OpenAI adds later cannot quietly start
178            // living on disk. Same rule Command Code follows.
179            cache.write_payload(&serde_json::to_vec(&response)?)?;
180            let snap = response.into_snapshot(plan_hint.as_deref())?;
181            Ok(crate::outcome::Outcome::fresh(snap))
182        }
183        Ok(Err(AppError::Http { status, body })) => {
184            cache.mark_stale();
185            let last_error = Some(cache.write_last_error(status, &body));
186            fallback(
187                cache,
188                plan_hint.as_deref(),
189                last_error,
190                AppError::Http { status, body },
191            )
192        }
193        Ok(Err(e)) if e.is_transient() => fallback_silent(cache, plan_hint.as_deref(), e),
194        Ok(Err(e)) => {
195            cache.mark_stale();
196            let last_error = Some(cache.write_last_error(0, &e.to_string()));
197            fallback(cache, plan_hint.as_deref(), last_error, e)
198        }
199        Err(_) => fallback_silent(
200            cache,
201            plan_hint.as_deref(),
202            AppError::Transport("openai: usage request timed out".into()),
203        ),
204    }
205}
206
207/// Lock the credential file `route` names and confirm, under that lock, that
208/// it still names the same one. A switch holds every directory it touches, the
209/// default slot's included, so an answer given under the lock stays true until
210/// the read-refresh-write below is done.
211async fn lock_route(route: &impl Fn() -> Result<PathBuf>) -> Result<(PathBuf, Option<LockGuard>)> {
212    let mut path = route()?;
213    for _ in 0..ROUTE_ATTEMPTS {
214        let lock = lock_credentials(&path).await?;
215        let settled = route()?;
216        if settled == path {
217            return Ok((path, lock));
218        }
219        path = settled;
220    }
221    Err(AppError::Transport(
222        "openai: the Codex login kept moving between accounts".into(),
223    ))
224}
225
226/// `account switch --codex` moves these files under the same lock, so a
227/// refresh can never write one account's tokens over another's. Only an
228/// existing directory is locked: creating one is not a fetch's business.
229async fn lock_credentials(creds_path: &Path) -> Result<Option<LockGuard>> {
230    let lock = super::account::lock_path(creds_path);
231    Ok(match lock.parent() {
232        Some(dir) if dir.is_dir() => {
233            Some(acquire_lock_async(&lock, CREDENTIALS_LOCK_TIMEOUT).await?)
234        }
235        _ => None,
236    })
237}
238
239fn reuse(
240    bytes: Vec<u8>,
241    cache: &Cache,
242    stale: bool,
243    plan_hint: Option<&str>,
244) -> Result<FetchOutcome> {
245    let snap = parse_payload(&bytes, plan_hint)?;
246    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
247}
248
249fn fallback(
250    cache: &Cache,
251    plan_hint: Option<&str>,
252    last_error: Option<(u16, String)>,
253    original: AppError,
254) -> Result<FetchOutcome> {
255    crate::outcome::fallback(cache, last_error, original, |bytes| {
256        parse_payload(bytes, plan_hint)
257    })
258}
259
260fn fallback_silent(
261    cache: &Cache,
262    plan_hint: Option<&str>,
263    original: AppError,
264) -> Result<FetchOutcome> {
265    crate::outcome::fallback(cache, None, original, |bytes| {
266        parse_payload(bytes, plan_hint)
267    })
268}
269
270/// The one place a *synthesized* error beats the original: the refresh failed,
271/// and "run `codex login` to re-auth" tells the user what to do about it,
272/// which the underlying OAuth error does not.
273fn handle_auth_failure(
274    cache: &Cache,
275    plan_hint: Option<&str>,
276    transient: bool,
277) -> Result<FetchOutcome> {
278    let original = if transient {
279        AppError::Transport("openai: no cache and refresh failed transiently".into())
280    } else {
281        AppError::Credentials("openai: token refresh failed; run `codex login` to re-auth".into())
282    };
283    crate::outcome::fallback(cache, None, original, |bytes| {
284        parse_payload(bytes, plan_hint)
285    })
286}
287
288fn parse_payload(bytes: &[u8], plan_hint: Option<&str>) -> Result<OpenAiSnapshot> {
289    parse_response(bytes)?.into_snapshot(plan_hint)
290}
291
292/// The wire response, before it becomes a snapshot. Split out so the live path
293/// can cache the parsed form rather than the raw body.
294fn parse_response(bytes: &[u8]) -> Result<UsageResponse> {
295    Ok(serde_json::from_slice(bytes)?)
296}
297
298fn authorized(client: &reqwest::Client, url: String, t: &Tokens) -> reqwest::RequestBuilder {
299    let mut req = client
300        .get(url)
301        .header("Authorization", format!("Bearer {}", t.access_token))
302        .header("User-Agent", "codex-cli");
303    if let Some(aid) = t.account_id.as_deref() {
304        req = req.header("ChatGPT-Account-Id", aid);
305    }
306    req
307}
308
309/// Cache the usage payload, grafting on the reset-credit expiries from the
310/// second endpoint when they exist. The graft is a field insert into the
311/// original JSON: re-serializing our typed `UsageResponse` would drop every
312/// unknown key the API still sends (`user_id`, tomorrow's new window, …),
313/// and a cache holding only the usage bytes would keep the count while the
314/// deadline beside it vanished for the rest of the TTL. The inserted
315/// `credits` array is our typed projection — status + expiry, never the
316/// redemption `id`.
317async fn fetch_usage(client: &reqwest::Client, url: &str, t: &Tokens) -> Result<UsageResponse> {
318    let resp = authorized(client, url.to_string(), t).send().await?;
319    let status = resp.status();
320    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
321
322    if !status.is_success() {
323        let body: String = String::from_utf8_lossy(&bytes).chars().take(200).collect();
324        return Err(AppError::Http {
325            status: status.as_u16(),
326            body,
327        });
328    }
329    let mut parsed: UsageResponse = serde_json::from_slice(&bytes)
330        .map_err(|e| AppError::Schema(format!("openai usage response: {e}")))?;
331    parsed.rate_limit_reset_credits = enrich_reset_credits(client, url, t, &parsed).await;
332    // Reject drift here, while the caller can still fall back to a good cache.
333    parsed.clone().into_snapshot(None)?;
334    Ok(parsed)
335}
336
337/// The usage endpoint reports how many banked resets exist but not when they
338/// expire; a second call carries the per-credit detail. That call is strictly
339/// additive: its failure leaves the count exactly as the usage endpoint
340/// reported it, because a count with no deadline is still true, and it is not
341/// worth failing a whole refresh over the deadline alone. The count itself
342/// always stays the usage endpoint's — the two responses can disagree across a
343/// redemption, and the one that also carries the quota figures is the one the
344/// rest of the snapshot is consistent with.
345async fn enrich_reset_credits(
346    client: &reqwest::Client,
347    usage_url: &str,
348    t: &Tokens,
349    parsed: &UsageResponse,
350) -> Option<super::types::ResetCreditsBlock> {
351    let mut block = parsed.rate_limit_reset_credits.clone()?;
352    if block.available_count == 0 {
353        return Some(block);
354    }
355    if let Ok(details) = fetch_reset_credits(client, usage_url, t).await {
356        block.credits = details.credits;
357    }
358    Some(block)
359}
360
361async fn fetch_reset_credits(
362    client: &reqwest::Client,
363    usage_url: &str,
364    t: &Tokens,
365) -> Result<super::types::ResetCreditsBlock> {
366    let base = usage_url.strip_suffix("/usage").unwrap_or(usage_url);
367    let resp = authorized(client, format!("{base}/rate-limit-reset-credits"), t)
368        .send()
369        .await?;
370    let status = resp.status();
371    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
372    if !status.is_success() {
373        // The body is discarded rather than reported: this call is optional,
374        // its failure never reaches the user, and it would only carry an
375        // account-identifying error into a log.
376        return Err(AppError::Http {
377            status: status.as_u16(),
378            body: String::new(),
379        });
380    }
381    serde_json::from_slice(&bytes)
382        .map_err(|_| AppError::Schema("openai reset credits response is invalid".into()))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use base64::Engine;
389    use std::io::Write;
390    use std::sync::atomic::{AtomicUsize, Ordering};
391    use tempfile::{NamedTempFile, TempDir};
392
393    fn fake_jwt(claims: serde_json::Value) -> String {
394        let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
395            .encode(br#"{"alg":"none","typ":"JWT"}"#);
396        let p =
397            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
398        format!("{h}.{p}.sig")
399    }
400
401    fn future_creds() -> NamedTempFile {
402        // exp 1h in the future.
403        let exp = Utc::now().timestamp() + 3600;
404        let jwt = fake_jwt(serde_json::json!({
405            "exp": exp,
406            "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
407        }));
408        let body = format!(
409            r#"{{"tokens":{{"access_token":"AT","refresh_token":"RT","id_token":"{jwt}",
410                "account_id":"acc"}}}}"#
411        );
412        let mut f = NamedTempFile::new().unwrap();
413        f.write_all(body.as_bytes()).unwrap();
414        f.flush().unwrap();
415        f
416    }
417
418    fn cache_fixture() -> (TempDir, Cache) {
419        let td = TempDir::new().unwrap();
420        let c = Cache::at(td.path().join("openai"));
421        c.ensure_dir().unwrap();
422        (td, c)
423    }
424
425    #[tokio::test]
426    async fn live_200_returns_snapshot_with_plan_from_id_token() {
427        let mut server = mockito::Server::new_async().await;
428        server
429            .mock("GET", "/backend-api/wham/usage")
430            .with_status(200)
431            .with_body(
432                r#"{"plan_type":"plus","rate_limit":{
433                "primary_window":{"used_percent":1,"limit_window_seconds":18000,"reset_at":1779597324},
434                "secondary_window":{"used_percent":0,"limit_window_seconds":604800,"reset_at":1780184124}
435            }}"#,
436            )
437            .create_async()
438            .await;
439        let (_td, cache) = cache_fixture();
440        let creds = future_creds();
441        let client = reqwest::Client::new();
442        let endpoints = Endpoints {
443            usage: format!("{}/backend-api/wham/usage", server.url()),
444            token: format!("{}/oauth/token", server.url()),
445        };
446        let out = fetch_snapshot(
447            &client,
448            creds.path(),
449            &cache,
450            &endpoints,
451            Duration::from_secs(0),
452        )
453        .await
454        .unwrap();
455        assert_eq!(out.snapshot.plan, "ChatGPT Plus");
456        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 1);
457        assert!(!out.stale);
458    }
459
460    #[tokio::test]
461    async fn weekly_only_primary_returns_weekly_snapshot() {
462        let mut server = mockito::Server::new_async().await;
463        server
464            .mock("GET", "/backend-api/wham/usage")
465            .with_status(200)
466            .with_body(
467                r#"{"plan_type":"prolite","rate_limit":{
468                "primary_window":{"used_percent":66,"limit_window_seconds":604800,"reset_at":1785261834},
469                "secondary_window":null
470            }}"#,
471            )
472            .create_async()
473            .await;
474        let (_td, cache) = cache_fixture();
475        let creds = future_creds();
476        let endpoints = Endpoints {
477            usage: format!("{}/backend-api/wham/usage", server.url()),
478            token: format!("{}/oauth/token", server.url()),
479        };
480        let out = fetch_snapshot(
481            &reqwest::Client::new(),
482            creds.path(),
483            &cache,
484            &endpoints,
485            Duration::from_secs(0),
486        )
487        .await
488        .unwrap();
489        assert!(out.snapshot.session.is_none());
490        assert_eq!(out.snapshot.weekly.unwrap().utilization_pct, 66);
491    }
492
493    #[tokio::test]
494    async fn corrupt_fresh_cache_refetches_instead_of_showing_an_empty_snapshot() {
495        // `reuse` used to swallow a parse failure into `empty(plan_hint)` and
496        // serve that all-zero snapshot for the rest of the TTL.
497        let mut server = mockito::Server::new_async().await;
498        server
499            .mock("GET", "/backend-api/wham/usage")
500            .with_status(200)
501            .with_body(
502                r#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":37,"limit_window_seconds":18000}}}"#,
503            )
504            .create_async()
505            .await;
506
507        let (_td, cache) = cache_fixture();
508        cache.write_payload(b"{ truncated").unwrap();
509
510        let creds = future_creds();
511        let client = reqwest::Client::new();
512        let endpoints = Endpoints {
513            usage: format!("{}/backend-api/wham/usage", server.url()),
514            token: format!("{}/oauth/token", server.url()),
515        };
516        // A long TTL: the payload IS fresh, it is simply unusable.
517        let out = fetch_snapshot(
518            &client,
519            creds.path(),
520            &cache,
521            &endpoints,
522            Duration::from_secs(3600),
523        )
524        .await
525        .unwrap();
526        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 37);
527        assert!(!out.stale);
528    }
529
530    /// The expiry lives behind a second endpoint. It has to reach the cache
531    /// with the usage figures, or the deadline disappears for the rest of the
532    /// TTL while the count beside it stays on screen.
533    #[tokio::test]
534    async fn banked_reset_expiries_are_fetched_and_cached_with_the_usage_figures() {
535        let mut server = mockito::Server::new_async().await;
536        let usage = server
537            .mock("GET", "/backend-api/wham/usage")
538            .with_body(
539                r#"{"plan_type":"plus","future_field":true,"rate_limit":{
540                    "primary_window":{"used_percent":81,"limit_window_seconds":18000,"reset_at":1786536977}},
541                    "rate_limit_reset_credits":{"available_count":2}}"#,
542            )
543            .create_async()
544            .await;
545        let details = server
546            .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
547            .with_body(
548                r#"{"available_count":2,"credits":[
549                    {"id":"c1","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-07-17T00:00:00Z"},
550                    {"id":"c2","status":"available","title":"Full reset (Weekly + 5 hr)","expires_at":"2026-08-01T00:00:00Z"}]}"#,
551            )
552            .create_async()
553            .await;
554
555        let (_td, cache) = cache_fixture();
556        let creds = future_creds();
557        let endpoints = Endpoints {
558            usage: format!("{}/backend-api/wham/usage", server.url()),
559            token: format!("{}/oauth/token", server.url()),
560        };
561        let out = fetch_snapshot(
562            &reqwest::Client::new(),
563            creds.path(),
564            &cache,
565            &endpoints,
566            Duration::from_secs(0),
567        )
568        .await
569        .unwrap();
570        usage.assert_async().await;
571        details.assert_async().await;
572        assert_eq!(out.snapshot.reset_credits.as_ref().unwrap().available, 2);
573        assert_eq!(
574            out.snapshot.reset_credits.as_ref().unwrap().next_expiry(),
575            Some("2026-07-17T00:00:00Z".parse().unwrap())
576        );
577
578        // The redemption id is what spends a credit; it must not be written to
579        // disk just because it shared a response with the expiry.
580        let cached = std::fs::read_to_string(cache.payload_path()).unwrap();
581        assert!(!cached.contains("\"c1\""), "{cached}");
582        // The cache holds the parsed response, so it holds only what a
583        // renderer reads. An unknown field is dropped rather than kept — the
584        // cache is a short-lived copy of something refetchable, and keeping
585        // the whole body is how the account's identity ended up on disk.
586        assert!(
587            !cached.contains("future_field"),
588            "the cache must not carry fields nothing parses: {cached}"
589        );
590        let reused = parse_payload(cached.as_bytes(), None).unwrap();
591        assert_eq!(reused.reset_credits, out.snapshot.reset_credits);
592    }
593
594    /// The response carries the account's identity — `user_id`, `account_id`
595    /// and `email` — and no renderer reads any of it. Caching the raw body put
596    /// all three on disk for the life of the TTL. Caching the *parsed*
597    /// response is an allowlist by construction: a field OpenAI adds later
598    /// cannot start living there without someone adding it to the type first.
599    #[tokio::test]
600    async fn the_cache_holds_no_account_identity() {
601        let mut server = mockito::Server::new_async().await;
602        let usage = server
603            .mock("GET", "/backend-api/wham/usage")
604            .with_body(
605                r#"{"plan_type":"pro",
606                    "user_id":"user_abc123",
607                    "account_id":"acct_abc123",
608                    "email":"person@example.test",
609                    "rate_limit":{"primary_window":{"used_percent":5,
610                                  "limit_window_seconds":604800}}}"#,
611            )
612            .create_async()
613            .await;
614
615        let (_td, cache) = cache_fixture();
616        let creds = future_creds();
617        let endpoints = Endpoints {
618            usage: format!("{}/backend-api/wham/usage", server.url()),
619            token: format!("{}/oauth/token", server.url()),
620        };
621        let out = fetch_snapshot(
622            &reqwest::Client::new(),
623            creds.path(),
624            &cache,
625            &endpoints,
626            Duration::from_secs(0),
627        )
628        .await
629        .unwrap();
630        usage.assert_async().await;
631
632        // The figures still arrive.
633        assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 5);
634
635        let cached = std::fs::read_to_string(cache.payload_path()).unwrap();
636        for identity in ["user_abc123", "acct_abc123", "person@example.test"] {
637            assert!(
638                !cached.contains(identity),
639                "{identity} reached the cache: {cached}"
640            );
641        }
642        for key in ["user_id", "account_id", "email"] {
643            assert!(!cached.contains(key), "{key} reached the cache: {cached}");
644        }
645    }
646
647    /// The detail call is an extra. When it fails, the count the usage
648    /// endpoint reported is still true and still worth showing — refusing the
649    /// whole refresh over a missing deadline would cost the quota figures too.
650    #[tokio::test]
651    async fn a_failed_detail_call_keeps_the_count_from_the_usage_response() {
652        let mut server = mockito::Server::new_async().await;
653        server
654            .mock("GET", "/backend-api/wham/usage")
655            .with_body(
656                r#"{"plan_type":"plus","rate_limit":{
657                    "primary_window":{"used_percent":10,"limit_window_seconds":18000}},
658                    "rate_limit_reset_credits":{"available_count":1}}"#,
659            )
660            .create_async()
661            .await;
662        let details = server
663            .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
664            .with_status(404)
665            .create_async()
666            .await;
667
668        let (_td, cache) = cache_fixture();
669        let creds = future_creds();
670        let endpoints = Endpoints {
671            usage: format!("{}/backend-api/wham/usage", server.url()),
672            token: format!("{}/oauth/token", server.url()),
673        };
674        let out = fetch_snapshot(
675            &reqwest::Client::new(),
676            creds.path(),
677            &cache,
678            &endpoints,
679            Duration::from_secs(0),
680        )
681        .await
682        .unwrap();
683        details.assert_async().await;
684        assert!(!out.stale);
685        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
686        assert_eq!(out.snapshot.reset_credits.as_ref().unwrap().available, 1);
687        assert!(
688            out.snapshot
689                .reset_credits
690                .as_ref()
691                .unwrap()
692                .credits
693                .is_empty()
694        );
695    }
696
697    /// With nothing banked there is nothing to detail. Asking anyway would
698    /// double every refresh's request count for every account that has none.
699    #[tokio::test]
700    async fn no_banked_resets_means_no_second_request() {
701        let mut server = mockito::Server::new_async().await;
702        server
703            .mock("GET", "/backend-api/wham/usage")
704            .with_body(
705                r#"{"plan_type":"plus","rate_limit":{
706                    "primary_window":{"used_percent":10,"limit_window_seconds":18000}},
707                    "rate_limit_reset_credits":{"available_count":0}}"#,
708            )
709            .create_async()
710            .await;
711        let details = server
712            .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
713            .expect(0)
714            .create_async()
715            .await;
716
717        let (_td, cache) = cache_fixture();
718        let creds = future_creds();
719        let endpoints = Endpoints {
720            usage: format!("{}/backend-api/wham/usage", server.url()),
721            token: format!("{}/oauth/token", server.url()),
722        };
723        let out = fetch_snapshot(
724            &reqwest::Client::new(),
725            creds.path(),
726            &cache,
727            &endpoints,
728            Duration::from_secs(0),
729        )
730        .await
731        .unwrap();
732        details.assert_async().await;
733        assert_eq!(
734            out.snapshot.reset_credits,
735            Some(crate::usage::ResetCredits::default())
736        );
737    }
738
739    #[tokio::test]
740    async fn http_500_falls_back_to_cache_when_present() {
741        let mut server = mockito::Server::new_async().await;
742        server
743            .mock("GET", "/backend-api/wham/usage")
744            .with_status(500)
745            .with_body(r#"{"error":{"message":"upstream"}}"#)
746            .create_async()
747            .await;
748        let (_td, cache) = cache_fixture();
749        cache
750            .write_payload(
751                br#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000}}}"#,
752            )
753            .unwrap();
754        let creds = future_creds();
755        let client = reqwest::Client::new();
756        let endpoints = Endpoints {
757            usage: format!("{}/backend-api/wham/usage", server.url()),
758            token: format!("{}/oauth/token", server.url()),
759        };
760        let out = fetch_snapshot(
761            &client,
762            creds.path(),
763            &cache,
764            &endpoints,
765            Duration::from_secs(0),
766        )
767        .await
768        .unwrap();
769        assert!(out.stale);
770        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 50);
771        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
772    }
773
774    /// Future-dated credentials at `path` whose access token is `token`.
775    fn write_creds(path: &Path, token: &str) {
776        let exp = Utc::now().timestamp() + 3600;
777        let jwt = fake_jwt(serde_json::json!({
778            "exp": exp,
779            "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
780        }));
781        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
782        std::fs::write(
783            path,
784            format!(
785                r#"{{"tokens":{{"access_token":"{token}","refresh_token":"RT",
786                    "id_token":"{jwt}","account_id":"acc"}}}}"#
787            ),
788        )
789        .unwrap();
790    }
791
792    #[tokio::test]
793    async fn a_route_that_moves_under_the_lock_is_followed() {
794        let mut server = mockito::Server::new_async().await;
795        let usage = server
796            .mock("GET", "/backend-api/wham/usage")
797            .match_header("authorization", "Bearer AT-B")
798            .with_status(200)
799            .with_body(
800                r#"{"plan_type":"plus","rate_limit":{
801                "primary_window":{"used_percent":7,"limit_window_seconds":18000,"reset_at":1779597324},
802                "secondary_window":null
803            }}"#,
804            )
805            .expect(1)
806            .create_async()
807            .await;
808        let (td, cache) = cache_fixture();
809        let a = td.path().join("a").join("auth.json");
810        let b = td.path().join("b").join("auth.json");
811        write_creds(&a, "AT-A");
812        write_creds(&b, "AT-B");
813        let endpoints = Endpoints {
814            usage: format!("{}/backend-api/wham/usage", server.url()),
815            token: format!("{}/oauth/token", server.url()),
816        };
817        // The first look names A; by the time A's lock is held, a switch has
818        // moved this account's login to B.
819        let looks = AtomicUsize::new(0);
820        let route = || {
821            Ok(if looks.fetch_add(1, Ordering::SeqCst) == 0 {
822                a.clone()
823            } else {
824                b.clone()
825            })
826        };
827        let out = fetch_snapshot_routed(
828            &reqwest::Client::new(),
829            route,
830            &cache,
831            &endpoints,
832            Duration::ZERO,
833        )
834        .await
835        .unwrap();
836        usage.assert_async().await;
837        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 7);
838        assert_eq!(looks.load(Ordering::SeqCst), 3);
839    }
840
841    #[tokio::test]
842    async fn a_route_that_never_settles_is_transient() {
843        let mut server = mockito::Server::new_async().await;
844        let usage = server
845            .mock("GET", "/backend-api/wham/usage")
846            .expect(0)
847            .create_async()
848            .await;
849        let (td, cache) = cache_fixture();
850        let endpoints = Endpoints {
851            usage: format!("{}/backend-api/wham/usage", server.url()),
852            token: format!("{}/oauth/token", server.url()),
853        };
854        let looks = AtomicUsize::new(0);
855        let route = || {
856            let look = looks.fetch_add(1, Ordering::SeqCst);
857            Ok(td.path().join(format!("auth-{look}.json")))
858        };
859        let error = fetch_snapshot_routed(
860            &reqwest::Client::new(),
861            route,
862            &cache,
863            &endpoints,
864            Duration::ZERO,
865        )
866        .await
867        .unwrap_err();
868        usage.assert_async().await;
869        assert!(error.is_transient(), "{error}");
870        assert_eq!(looks.load(Ordering::SeqCst), ROUTE_ATTEMPTS + 1);
871    }
872
873    #[tokio::test]
874    async fn a_blank_access_token_is_a_credentials_error_not_a_rate_limit() {
875        // Signed-out credentials file: `.expect(0)` on every endpoint is the
876        // assertion - a blank Bearer must never reach the network, where it
877        // would earn a 401/429 and the 429 variant would arm the backoff.
878        let mut server = mockito::Server::new_async().await;
879        let refresh = server
880            .mock("POST", "/oauth/token")
881            .expect(0)
882            .create_async()
883            .await;
884        let usage = server
885            .mock("GET", "/backend-api/wham/usage")
886            .expect(0)
887            .create_async()
888            .await;
889
890        let (_td, cache) = cache_fixture();
891        let mut f = NamedTempFile::new().unwrap();
892        f.write_all(
893            br#"{"tokens":{
894                "access_token":"","refresh_token":"",
895                "id_token":"","account_id":"acc"
896            }}"#,
897        )
898        .unwrap();
899        f.flush().unwrap();
900
901        let client = reqwest::Client::new();
902        let endpoints = Endpoints {
903            usage: format!("{}/backend-api/wham/usage", server.url()),
904            token: format!("{}/oauth/token", server.url()),
905        };
906        let err = fetch_snapshot(
907            &client,
908            f.path(),
909            &cache,
910            &endpoints,
911            Duration::from_secs(0),
912        )
913        .await
914        .unwrap_err();
915
916        assert!(
917            matches!(err, AppError::Credentials(ref msg) if msg.contains("run `codex login`")),
918            "blank access token must be a credentials error with the login hint, got {err:?}"
919        );
920        assert!(
921            cache.backoff_remaining().is_none(),
922            "a signed-out file must never arm the 429 backoff"
923        );
924        refresh.assert_async().await;
925        usage.assert_async().await;
926    }
927
928    #[tokio::test]
929    async fn a_blank_access_token_self_repairs_when_refresh_token_is_valid() {
930        let mut server = mockito::Server::new_async().await;
931        let refresh = server
932            .mock("POST", "/oauth/token")
933            .with_status(200)
934            .with_body(r#"{"access_token":"AT-FRESH","refresh_token":"RT-NEW","expires_in":3600}"#)
935            .expect(1)
936            .create_async()
937            .await;
938        let usage = server
939            .mock("GET", "/backend-api/wham/usage")
940            .match_header("authorization", "Bearer AT-FRESH")
941            .with_status(200)
942            .with_body(
943                r#"{"plan_type":"plus","rate_limit":{
944                    "primary_window":{"used_percent":5,"limit_window_seconds":18000,"reset_at":1779597324},
945                    "secondary_window":null
946                }}"#,
947            )
948            .expect(1)
949            .create_async()
950            .await;
951
952        let (td, cache) = cache_fixture();
953        let creds_path = td.path().join("auth.json");
954        std::fs::write(
955            &creds_path,
956            br#"{"tokens":{
957                "access_token":"","refresh_token":"RT-OLD",
958                "id_token":"","account_id":"acc"
959            }}"#,
960        )
961        .unwrap();
962
963        let client = reqwest::Client::new();
964        let endpoints = Endpoints {
965            usage: format!("{}/backend-api/wham/usage", server.url()),
966            token: format!("{}/oauth/token", server.url()),
967        };
968        let out = fetch_snapshot(
969            &client,
970            &creds_path,
971            &cache,
972            &endpoints,
973            Duration::from_secs(0),
974        )
975        .await
976        .unwrap();
977
978        refresh.assert_async().await;
979        usage.assert_async().await;
980        assert!(!out.stale);
981        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 5);
982    }
983
984    #[tokio::test]
985    async fn empty_refresh_token_clears_old_last_error_on_transient_fallback() {
986        let mut server = mockito::Server::new_async().await;
987        let refresh = server
988            .mock("POST", "/oauth/token")
989            .expect(0)
990            .create_async()
991            .await;
992
993        let (_td, cache) = cache_fixture();
994        cache
995            .write_payload(
996                br#"{"plan_type":"plus","rate_limit":{
997                    "primary_window":{"used_percent":12,"limit_window_seconds":18000,"reset_at":1779597324},
998                    "secondary_window":null
999                }}"#,
1000            )
1001            .unwrap();
1002        cache.write_last_error(400, "Invalid request format");
1003
1004        // Expired credentials with a live access token but no refresh token.
1005        let mut f = NamedTempFile::new().unwrap();
1006        f.write_all(
1007            br#"{"tokens":{
1008                "access_token":"AT-VALID","refresh_token":"",
1009                "id_token":"","account_id":"acc"
1010            }}"#,
1011        )
1012        .unwrap();
1013        f.flush().unwrap();
1014
1015        let client = reqwest::Client::builder()
1016            .timeout(Duration::from_millis(200))
1017            .build()
1018            .unwrap();
1019        let endpoints = Endpoints {
1020            usage: "http://127.0.0.1:1/backend-api/wham/usage".into(),
1021            token: format!("{}/oauth/token", server.url()),
1022        };
1023        let outcome = fetch_snapshot(
1024            &client,
1025            f.path(),
1026            &cache,
1027            &endpoints,
1028            Duration::from_secs(0),
1029        )
1030        .await
1031        .unwrap();
1032
1033        assert!(outcome.stale);
1034        assert!(outcome.last_error.is_none());
1035        assert!(cache.read_last_error().is_none());
1036        refresh.assert_async().await;
1037    }
1038}