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.available, 2);
573        assert_eq!(
574            out.snapshot.reset_credits.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.available, 1);
687        assert!(out.snapshot.reset_credits.credits.is_empty());
688    }
689
690    /// With nothing banked there is nothing to detail. Asking anyway would
691    /// double every refresh's request count for every account that has none.
692    #[tokio::test]
693    async fn no_banked_resets_means_no_second_request() {
694        let mut server = mockito::Server::new_async().await;
695        server
696            .mock("GET", "/backend-api/wham/usage")
697            .with_body(
698                r#"{"plan_type":"plus","rate_limit":{
699                    "primary_window":{"used_percent":10,"limit_window_seconds":18000}},
700                    "rate_limit_reset_credits":{"available_count":0}}"#,
701            )
702            .create_async()
703            .await;
704        let details = server
705            .mock("GET", "/backend-api/wham/rate-limit-reset-credits")
706            .expect(0)
707            .create_async()
708            .await;
709
710        let (_td, cache) = cache_fixture();
711        let creds = future_creds();
712        let endpoints = Endpoints {
713            usage: format!("{}/backend-api/wham/usage", server.url()),
714            token: format!("{}/oauth/token", server.url()),
715        };
716        let out = fetch_snapshot(
717            &reqwest::Client::new(),
718            creds.path(),
719            &cache,
720            &endpoints,
721            Duration::from_secs(0),
722        )
723        .await
724        .unwrap();
725        details.assert_async().await;
726        assert!(out.snapshot.reset_credits.is_empty());
727    }
728
729    #[tokio::test]
730    async fn http_500_falls_back_to_cache_when_present() {
731        let mut server = mockito::Server::new_async().await;
732        server
733            .mock("GET", "/backend-api/wham/usage")
734            .with_status(500)
735            .with_body(r#"{"error":{"message":"upstream"}}"#)
736            .create_async()
737            .await;
738        let (_td, cache) = cache_fixture();
739        cache
740            .write_payload(
741                br#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":50,"limit_window_seconds":18000}}}"#,
742            )
743            .unwrap();
744        let creds = future_creds();
745        let client = reqwest::Client::new();
746        let endpoints = Endpoints {
747            usage: format!("{}/backend-api/wham/usage", server.url()),
748            token: format!("{}/oauth/token", server.url()),
749        };
750        let out = fetch_snapshot(
751            &client,
752            creds.path(),
753            &cache,
754            &endpoints,
755            Duration::from_secs(0),
756        )
757        .await
758        .unwrap();
759        assert!(out.stale);
760        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 50);
761        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(500));
762    }
763
764    /// Future-dated credentials at `path` whose access token is `token`.
765    fn write_creds(path: &Path, token: &str) {
766        let exp = Utc::now().timestamp() + 3600;
767        let jwt = fake_jwt(serde_json::json!({
768            "exp": exp,
769            "https://api.openai.com/auth": {"chatgpt_plan_type": "plus"}
770        }));
771        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
772        std::fs::write(
773            path,
774            format!(
775                r#"{{"tokens":{{"access_token":"{token}","refresh_token":"RT",
776                    "id_token":"{jwt}","account_id":"acc"}}}}"#
777            ),
778        )
779        .unwrap();
780    }
781
782    #[tokio::test]
783    async fn a_route_that_moves_under_the_lock_is_followed() {
784        let mut server = mockito::Server::new_async().await;
785        let usage = server
786            .mock("GET", "/backend-api/wham/usage")
787            .match_header("authorization", "Bearer AT-B")
788            .with_status(200)
789            .with_body(
790                r#"{"plan_type":"plus","rate_limit":{
791                "primary_window":{"used_percent":7,"limit_window_seconds":18000,"reset_at":1779597324},
792                "secondary_window":null
793            }}"#,
794            )
795            .expect(1)
796            .create_async()
797            .await;
798        let (td, cache) = cache_fixture();
799        let a = td.path().join("a").join("auth.json");
800        let b = td.path().join("b").join("auth.json");
801        write_creds(&a, "AT-A");
802        write_creds(&b, "AT-B");
803        let endpoints = Endpoints {
804            usage: format!("{}/backend-api/wham/usage", server.url()),
805            token: format!("{}/oauth/token", server.url()),
806        };
807        // The first look names A; by the time A's lock is held, a switch has
808        // moved this account's login to B.
809        let looks = AtomicUsize::new(0);
810        let route = || {
811            Ok(if looks.fetch_add(1, Ordering::SeqCst) == 0 {
812                a.clone()
813            } else {
814                b.clone()
815            })
816        };
817        let out = fetch_snapshot_routed(
818            &reqwest::Client::new(),
819            route,
820            &cache,
821            &endpoints,
822            Duration::ZERO,
823        )
824        .await
825        .unwrap();
826        usage.assert_async().await;
827        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 7);
828        assert_eq!(looks.load(Ordering::SeqCst), 3);
829    }
830
831    #[tokio::test]
832    async fn a_route_that_never_settles_is_transient() {
833        let mut server = mockito::Server::new_async().await;
834        let usage = server
835            .mock("GET", "/backend-api/wham/usage")
836            .expect(0)
837            .create_async()
838            .await;
839        let (td, cache) = cache_fixture();
840        let endpoints = Endpoints {
841            usage: format!("{}/backend-api/wham/usage", server.url()),
842            token: format!("{}/oauth/token", server.url()),
843        };
844        let looks = AtomicUsize::new(0);
845        let route = || {
846            let look = looks.fetch_add(1, Ordering::SeqCst);
847            Ok(td.path().join(format!("auth-{look}.json")))
848        };
849        let error = fetch_snapshot_routed(
850            &reqwest::Client::new(),
851            route,
852            &cache,
853            &endpoints,
854            Duration::ZERO,
855        )
856        .await
857        .unwrap_err();
858        usage.assert_async().await;
859        assert!(error.is_transient(), "{error}");
860        assert_eq!(looks.load(Ordering::SeqCst), ROUTE_ATTEMPTS + 1);
861    }
862
863    #[tokio::test]
864    async fn a_blank_access_token_is_a_credentials_error_not_a_rate_limit() {
865        // Signed-out credentials file: `.expect(0)` on every endpoint is the
866        // assertion - a blank Bearer must never reach the network, where it
867        // would earn a 401/429 and the 429 variant would arm the backoff.
868        let mut server = mockito::Server::new_async().await;
869        let refresh = server
870            .mock("POST", "/oauth/token")
871            .expect(0)
872            .create_async()
873            .await;
874        let usage = server
875            .mock("GET", "/backend-api/wham/usage")
876            .expect(0)
877            .create_async()
878            .await;
879
880        let (_td, cache) = cache_fixture();
881        let mut f = NamedTempFile::new().unwrap();
882        f.write_all(
883            br#"{"tokens":{
884                "access_token":"","refresh_token":"",
885                "id_token":"","account_id":"acc"
886            }}"#,
887        )
888        .unwrap();
889        f.flush().unwrap();
890
891        let client = reqwest::Client::new();
892        let endpoints = Endpoints {
893            usage: format!("{}/backend-api/wham/usage", server.url()),
894            token: format!("{}/oauth/token", server.url()),
895        };
896        let err = fetch_snapshot(
897            &client,
898            f.path(),
899            &cache,
900            &endpoints,
901            Duration::from_secs(0),
902        )
903        .await
904        .unwrap_err();
905
906        assert!(
907            matches!(err, AppError::Credentials(ref msg) if msg.contains("run `codex login`")),
908            "blank access token must be a credentials error with the login hint, got {err:?}"
909        );
910        assert!(
911            cache.backoff_remaining().is_none(),
912            "a signed-out file must never arm the 429 backoff"
913        );
914        refresh.assert_async().await;
915        usage.assert_async().await;
916    }
917
918    #[tokio::test]
919    async fn a_blank_access_token_self_repairs_when_refresh_token_is_valid() {
920        let mut server = mockito::Server::new_async().await;
921        let refresh = server
922            .mock("POST", "/oauth/token")
923            .with_status(200)
924            .with_body(r#"{"access_token":"AT-FRESH","refresh_token":"RT-NEW","expires_in":3600}"#)
925            .expect(1)
926            .create_async()
927            .await;
928        let usage = server
929            .mock("GET", "/backend-api/wham/usage")
930            .match_header("authorization", "Bearer AT-FRESH")
931            .with_status(200)
932            .with_body(
933                r#"{"plan_type":"plus","rate_limit":{
934                    "primary_window":{"used_percent":5,"limit_window_seconds":18000,"reset_at":1779597324},
935                    "secondary_window":null
936                }}"#,
937            )
938            .expect(1)
939            .create_async()
940            .await;
941
942        let (td, cache) = cache_fixture();
943        let creds_path = td.path().join("auth.json");
944        std::fs::write(
945            &creds_path,
946            br#"{"tokens":{
947                "access_token":"","refresh_token":"RT-OLD",
948                "id_token":"","account_id":"acc"
949            }}"#,
950        )
951        .unwrap();
952
953        let client = reqwest::Client::new();
954        let endpoints = Endpoints {
955            usage: format!("{}/backend-api/wham/usage", server.url()),
956            token: format!("{}/oauth/token", server.url()),
957        };
958        let out = fetch_snapshot(
959            &client,
960            &creds_path,
961            &cache,
962            &endpoints,
963            Duration::from_secs(0),
964        )
965        .await
966        .unwrap();
967
968        refresh.assert_async().await;
969        usage.assert_async().await;
970        assert!(!out.stale);
971        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 5);
972    }
973
974    #[tokio::test]
975    async fn empty_refresh_token_clears_old_last_error_on_transient_fallback() {
976        let mut server = mockito::Server::new_async().await;
977        let refresh = server
978            .mock("POST", "/oauth/token")
979            .expect(0)
980            .create_async()
981            .await;
982
983        let (_td, cache) = cache_fixture();
984        cache
985            .write_payload(
986                br#"{"plan_type":"plus","rate_limit":{
987                    "primary_window":{"used_percent":12,"limit_window_seconds":18000,"reset_at":1779597324},
988                    "secondary_window":null
989                }}"#,
990            )
991            .unwrap();
992        cache.write_last_error(400, "Invalid request format");
993
994        // Expired credentials with a live access token but no refresh token.
995        let mut f = NamedTempFile::new().unwrap();
996        f.write_all(
997            br#"{"tokens":{
998                "access_token":"AT-VALID","refresh_token":"",
999                "id_token":"","account_id":"acc"
1000            }}"#,
1001        )
1002        .unwrap();
1003        f.flush().unwrap();
1004
1005        let client = reqwest::Client::builder()
1006            .timeout(Duration::from_millis(200))
1007            .build()
1008            .unwrap();
1009        let endpoints = Endpoints {
1010            usage: "http://127.0.0.1:1/backend-api/wham/usage".into(),
1011            token: format!("{}/oauth/token", server.url()),
1012        };
1013        let outcome = fetch_snapshot(
1014            &client,
1015            f.path(),
1016            &cache,
1017            &endpoints,
1018            Duration::from_secs(0),
1019        )
1020        .await
1021        .unwrap();
1022
1023        assert!(outcome.stale);
1024        assert!(outcome.last_error.is_none());
1025        assert!(cache.read_last_error().is_none());
1026        refresh.assert_async().await;
1027    }
1028}