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.available, 2);
573 assert_eq!(
574 out.snapshot.reset_credits.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.available, 1);
687 assert!(out.snapshot.reset_credits.credits.is_empty());
688 }
689
690 #[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 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 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 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 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}