1use 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);
22const CREDENTIALS_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
24const 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
43pub 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
58pub 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 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 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 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 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 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 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.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
207async 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
226async 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
270fn 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
292fn 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
309async 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 parsed.clone().into_snapshot(None)?;
334 Ok(parsed)
335}
336
337async 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 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 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 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 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 #[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 let cached = std::fs::read_to_string(cache.payload_path()).unwrap();
581 assert!(!cached.contains("\"c1\""), "{cached}");
582 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 #[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 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 #[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 #[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 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 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 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 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}