From 25fc04742baf24b5c2fb8ed97b77ad673554e9bc Mon Sep 17 00:00:00 2001 From: Bradley Wadsworth Date: Sat, 12 Sep 2026 21:08:16 +0100 Subject: [PATCH] fix: improve Codex usage polling reliability --- README.md | 6 +- src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 2 + src-tauri/src/codex.rs | 347 ++++++++++++++++++++++++++++++++++----- src-tauri/src/history.rs | 3 + src-tauri/src/poller.rs | 154 +++++++++++++++-- src-tauri/src/usage.rs | 15 ++ src/api.ts | 4 + src/main.ts | 23 +-- src/status.test.ts | 44 +++++ src/status.ts | 44 +++++ 11 files changed, 580 insertions(+), 69 deletions(-) create mode 100644 src/status.test.ts create mode 100644 src/status.ts diff --git a/README.md b/README.md index b1985f6..b3cc283 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Hover over the widget to reveal its buttons. The grab handle is separated from t | Drag the bottom-right grip | Resize the widget | | Click a provider icon | Switch between Claude and Codex | | Click Pin | Keep the widget above other windows | -| Click Refresh | Recheck local providers and retry usage immediately | +| Click Refresh | Recheck local providers and retry usage (Codex respects server cooldowns) | | Click Settings | Adjust appearance, layout, limits, controls, and startup preferences | | Click Close | Hide the widget; restore it from the tray | | Hover over the graph | Inspect usage and time at the cursor | @@ -118,6 +118,10 @@ Tokometer reads `$CODEX_HOME/auth.json`, or `~/.codex/auth.json` by default, and API-key and other non-ChatGPT login modes are not supported for subscription usage tracking. If the login expires, refresh it through Codex and then click Refresh in Tokometer. This implementation follows the direct OAuth approach documented by [CodexBar](https://github.com/steipete/CodexBar/blob/1c4650d1b421bda3f0e495e8c14b69dc1b3de81d/docs/codex-oauth.md). The endpoint is internal and may change. +Codex requests allow up to 30 seconds. Temporary connection failures, timeouts, and selected server errors receive one retry after two seconds. Rate limits use increasing cooldowns and honor the server's `Retry-After` header; Refresh and provider switching do not bypass an active account cooldown. Login errors appear immediately with guidance to reopen Codex. + +When a temporary failure occurs, the last reading stays faded with its age. The first two failed polls show a quiet “Updating…” message while the reading is under five minutes old; continued failures show the specific error. Stale readings are never added to usage history. Keep just one Tokometer instance running during normal use to avoid duplicate polling from development or preview builds. + ### Local history The providers report current usage, so Tokometer records its own time series in `history.json` alongside its settings. Recent samples retain full resolution; older history is reduced to five-minute intervals and retained for up to 15 days. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7bcda2c..d3154d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1594,6 +1594,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -4244,6 +4250,7 @@ version = "1.5.0" dependencies = [ "ab_glyph", "base64 0.22.1", + "httpdate", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f134102..930a0af 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" base64 = "0.22" +# Parse standard Retry-After HTTP dates without a date/time runtime. +httpdate = "1" # Pure-Rust glyph rasterizer for the two-line text tray icon (cross-platform). ab_glyph = "0.2" # native-tls (schannel) so corporate root CAs in the Windows cert store work diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index d03c450..0fcc0c6 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -3,6 +3,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::path::PathBuf; +use std::time::{Duration, UNIX_EPOCH}; use crate::state::Provider; use crate::usage::{LimitWindow, UsageSnapshot, ID_SESSION, ID_WEEKLY_ALL}; @@ -178,61 +179,173 @@ fn parse_usage(v: &Value, scope: &str) -> Result { fn request(client: &reqwest::Client, credentials: &Credentials) -> reqwest::RequestBuilder { client .get(USAGE_URL) + .timeout(Duration::from_secs(30)) .bearer_auth(&credentials.token) .header("ChatGPT-Account-Id", &credentials.account) .header("Accept", "application/json") } -/// Return the snapshot and whether to back off. Error bodies never reach logs or the UI. -pub async fn poll(client: &reqwest::Client) -> (UsageSnapshot, bool) { +pub struct PollResult { + pub snapshot: UsageSnapshot, + pub transient: bool, + pub rate_limited: bool, +} + +impl PollResult { + fn failed(message: String, scope: Option<&str>) -> Self { + Self { + snapshot: error(message, scope), + transient: false, + rate_limited: false, + } + } +} + +/// Read credentials even during a cooldown, so a different account can poll immediately. +/// Credentials are never refreshed or written here; error bodies stay out of the UI/logs. +pub async fn poll(client: &reqwest::Client, cooldown: Option<(&str, i64)>) -> Option { let credentials = match read_credentials() { Ok(c) => c, - Err(e) => return (error(e, None), false), + Err(e) => return Some(PollResult::failed(e, None)), }; - let (result, rate_limited) = - fetch_usage(request(client, &credentials), &credentials.scope).await; + if cooldown_active(cooldown, &credentials.scope, crate::usage::now_ms()) { + return None; + } + let result = fetch_with_retry( + request(client, &credentials), + &credentials.scope, + Duration::from_secs(2), + ) + .await; // Don't publish a response for an account that was replaced during the request. match read_credentials() { Ok(current) if current.scope != credentials.scope => { - return ( - error( - "Codex account changed — refreshing".into(), - Some(¤t.scope), - ), - false, - ) + return Some(PollResult::failed( + "Codex account changed — refreshing".into(), + Some(¤t.scope), + )); } - Err(e) => return (error(e, None), false), + Err(e) => return Some(PollResult::failed(e, None)), _ => {} } - ( - result.unwrap_or_else(|e| error(e, Some(&credentials.scope))), - rate_limited, - ) + Some(match result { + Ok(snapshot) => PollResult { + snapshot, + transient: false, + rate_limited: false, + }, + Err(failure) => { + let mut snapshot = error(failure.message, Some(&credentials.scope)); + snapshot.retry_at = failure.retry_at; + PollResult { + snapshot, + transient: failure.transient, + rate_limited: failure.rate_limited, + } + } + }) +} + +fn cooldown_active(cooldown: Option<(&str, i64)>, scope: &str, now: i64) -> bool { + cooldown.is_some_and(|(account, deadline)| account == scope && now < deadline) +} + +#[derive(Debug)] +struct FetchFailure { + message: String, + transient: bool, + rate_limited: bool, + retry_at: Option, +} + +impl FetchFailure { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + transient: false, + rate_limited: false, + retry_at: None, + } + } + + fn transport(error: reqwest::Error) -> Self { + let mut failure = Self::new(if error.is_timeout() { + "Codex usage request timed out — retrying" + } else { + "Cannot reach Codex usage — check your connection" + }); + failure.transient = + error.is_timeout() || error.is_connect() || error.is_body() || error.is_request(); + failure + } +} + +fn retry_after(value: &str, now: i64) -> Option { + let value = value.trim(); + if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) { + let seconds: u64 = value.parse().ok()?; + return Some( + now.saturating_add(i64::try_from(seconds.saturating_mul(1000)).unwrap_or(i64::MAX)), + ); + } + let date = httpdate::parse_http_date(value).ok()?; + let millis = i64::try_from(date.duration_since(UNIX_EPOCH).ok()?.as_millis()).ok()?; + Some(millis.max(now)) +} + +async fn fetch_with_retry( + builder: reqwest::RequestBuilder, + scope: &str, + delay: Duration, +) -> Result { + let retry = builder.try_clone(); + let result = fetch_usage(builder, scope).await; + // Never retry auth, malformed data or 429 here. A server-requested pause + // belongs to the poll scheduler instead of keeping a request task asleep. + if result + .as_ref() + .is_err_and(|e| e.transient && !e.rate_limited && e.retry_at.is_none()) + { + if let Some(retry) = retry { + tokio::time::sleep(delay).await; + return fetch_usage(retry, scope).await; + } + } + result } async fn fetch_usage( builder: reqwest::RequestBuilder, scope: &str, -) -> (Result, bool) { - let mut rate_limited = false; - let result = match builder.send().await { - Ok(response) => match response.status().as_u16() { - 200..=299 => match response.json::().await { - Ok(body) => parse_usage(&body, scope), - Err(_) => Err("Codex usage returned an unreadable response".into()), - }, - 401 => Err("Codex login expired — open Codex to refresh your login, then retry".into()), - 403 => Err("Codex usage access denied for this account".into()), - 429 => { - rate_limited = true; - Err("Codex usage rate-limited — retrying after a short pause".into()) +) -> Result { + let response = builder.send().await.map_err(FetchFailure::transport)?; + let status = response.status().as_u16(); + if (200..=299).contains(&status) { + let body = response.json::().await.map_err(|e| { + if e.is_timeout() || e.is_body() { + FetchFailure::transport(e) + } else { + FetchFailure::new("Codex usage returned an unreadable response") } - status => Err(format!("Codex usage: HTTP {status}")), - }, - Err(_) => Err("Cannot reach Codex usage — check your connection".into()), - }; - (result, rate_limited) + })?; + return parse_usage(&body, scope).map_err(FetchFailure::new); + } + let mut failure = FetchFailure::new(match status { + 401 => "Codex login expired — open Codex to refresh your login, then retry".into(), + 403 => "Codex usage access denied for this account".into(), + 429 => "Codex usage rate-limited — waiting before retrying".into(), + status => format!("Codex usage: HTTP {status}"), + }); + failure.rate_limited = status == 429; + failure.transient = matches!(status, 408 | 500 | 502 | 503 | 504); + if failure.rate_limited || failure.transient { + failure.retry_at = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| retry_after(v, crate::usage::now_ms())); + } + Err(failure) } #[cfg(test)] @@ -267,16 +380,174 @@ mod tests { .timeout(std::time::Duration::from_secs(5)) .build() .unwrap(); - let (result, backoff) = + let result = tauri::async_runtime::block_on(fetch_usage(client.get(url), "codex:fixture")); - let message = result.unwrap_err(); + let failure = result.unwrap_err(); + let message = failure.message; assert!(message.starts_with(expected), "{message}"); assert!(!message.contains("private-response-secret")); - assert_eq!(backoff, limited); + assert_eq!(failure.rate_limited, limited); + assert_eq!(failure.transient, status == 500); server.join().unwrap(); } } + #[test] + fn retry_after_accepts_seconds_and_http_dates() { + let now = 1_800_000_000_000; + assert_eq!(retry_after("120", now), Some(now + 120_000)); + assert_eq!(retry_after("0", now), Some(now)); + let date = + httpdate::fmt_http_date(UNIX_EPOCH + Duration::from_millis((now + 90_000) as u64)); + assert_eq!(retry_after(&date, now), Some(now + 90_000)); + assert_eq!(retry_after(&date, now + 120_000), Some(now + 120_000)); + assert_eq!(retry_after("18446744073709551615", now), Some(i64::MAX)); + for invalid in ["", "invalid", "-5", "+5", "1.5"] { + assert_eq!(retry_after(invalid, now), None); + } + } + + #[test] + fn cooldown_is_bound_to_account_and_deadline() { + let cooldown = Some(("codex:a", 1000)); + assert!(cooldown_active(cooldown, "codex:a", 999)); + assert!(!cooldown_active(cooldown, "codex:a", 1000)); + assert!(!cooldown_active(cooldown, "codex:b", 999)); + assert!(!cooldown_active(None, "codex:a", 999)); + } + + // All retry checks use loopback responses and fake credentials, never OpenAI. + fn retry_fixture(responses: Vec<(u16, &str, &str)>) -> Result { + use std::io::{Read, Write}; + let responses: Vec<_> = responses + .into_iter() + .map(|(status, headers, body)| (status, headers.to_string(), body.to_string())) + .collect(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/usage", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + for (status, headers, body) in responses { + let mut socket = loop { + match listener.accept() { + Ok((socket, _)) => break socket, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "missing expected retry" + ); + std::thread::sleep(Duration::from_millis(1)); + } + Err(e) => panic!("fixture accept: {e}"), + } + }; + socket.set_nonblocking(false).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _ = socket.read(&mut [0; 4096]).unwrap(); + write!(socket, "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len()).unwrap(); + } + }); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(2)) + .build() + .unwrap(); + let result = tauri::async_runtime::block_on(fetch_with_retry( + client.get(url), + "codex:fixture", + Duration::from_millis(1), + )); + server.join().unwrap(); + result + } + + #[test] + fn transient_server_failure_retries_once_and_can_recover() { + let valid = + r#"{"rate_limit":{"primary_window":{"used_percent":21,"limit_window_seconds":18000}}}"#; + let recovered = retry_fixture(vec![(503, "", "secret"), (200, "", valid)]).unwrap(); + assert_eq!(recovered.windows[0].utilization, 21.0); + let failed = retry_fixture(vec![(502, "", "secret"), (502, "", "secret")]).unwrap_err(); + assert_eq!(failed.message, "Codex usage: HTTP 502"); + assert!(failed.transient); + } + + #[test] + fn auth_invalid_data_and_server_cooldowns_never_get_an_immediate_retry() { + for (status, headers, message) in [ + (401, "", "Codex login expired"), + (403, "", "Codex usage access denied"), + (200, "", "Codex usage returned an unreadable response"), + (429, "", "Codex usage rate-limited"), + (429, "Retry-After: 120\r\n", "Codex usage rate-limited"), + (503, "Retry-After: 120\r\n", "Codex usage: HTTP 503"), + ] { + let start = crate::usage::now_ms(); + let failure = retry_fixture(vec![(status, headers, "secret")]).unwrap_err(); + assert!(failure.message.starts_with(message), "{}", failure.message); + assert_eq!(failure.rate_limited, status == 429); + if !headers.is_empty() { + assert!(failure.retry_at.unwrap() >= start + 120_000); + } + } + } + + #[test] + fn transport_timeouts_retry_once_and_remain_distinct_from_http_errors() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/usage", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut sockets = Vec::new(); + while sockets.len() < 2 { + match listener.accept() { + Ok((socket, _)) => sockets.push(socket), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timeout was not retried" + ); + std::thread::sleep(Duration::from_millis(1)); + } + Err(e) => panic!("fixture accept: {e}"), + } + } + // Keep both connections open without replying until the client times out. + std::thread::sleep(Duration::from_millis(100)); + }); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(30)) + .build() + .unwrap(); + let failure = tauri::async_runtime::block_on(fetch_with_retry( + client.get(url).bearer_auth("private-token"), + "codex:fixture", + Duration::from_millis(1), + )) + .unwrap_err(); + server.join().unwrap(); + assert_eq!(failure.message, "Codex usage request timed out — retrying"); + assert!(failure.transient); + assert!(!failure.rate_limited); + assert!(failure.retry_at.is_none()); + } + + #[test] + fn codex_timeout_overrides_the_shared_clients_shorter_timeout() { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .unwrap(); + let credentials = parse_credentials(&auth("account", "subject", "fake-token")).unwrap(); + let built = request(&client, &credentials).build().unwrap(); + assert_eq!(built.timeout(), Some(&Duration::from_secs(30))); + } + fn auth(account: &str, subject: &str, token: &str) -> String { let jwt = format!( "e30.{}.signature", diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index 17f638c..4787032 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -272,6 +272,9 @@ mod tests { window_seconds: None, }], error: None, + last_success_at: None, + retry_at: None, + recovering: false, } } diff --git a/src-tauri/src/poller.rs b/src-tauri/src/poller.rs index c8f4375..0803983 100644 --- a/src-tauri/src/poller.rs +++ b/src-tauri/src/poller.rs @@ -46,10 +46,21 @@ pub fn spawn(app: AppHandle) { let mut rate_limited_polls: u32 = 0; let mut next_oauth_ms: i64 = 0; let mut last_revision = u64::MAX; + // Separate from Claude's schedule: manual refresh and provider switching + // must not bypass a Codex server cooldown for the same account. + let mut codex_cooldown: Option<(String, i64, u32)> = None; loop { // Provider visibility reflects local use, independent of API health. - publish_availability(&app, crate::state::Provider::Claude, crate::credentials::is_present()); - publish_availability(&app, crate::state::Provider::Codex, crate::codex::is_present()); + publish_availability( + &app, + crate::state::Provider::Claude, + crate::credentials::is_present(), + ); + publish_availability( + &app, + crate::state::Provider::Codex, + crate::codex::is_present(), + ); let (provider, revision, probe_enabled) = { let state = app.state::(); let s = state.0.lock().unwrap(); @@ -69,17 +80,67 @@ pub fn spawn(app: AppHandle) { probe_enabled && now - last_probe_ms >= PROBE_MIN_INTERVAL_MS; poll_once(&client, try_oauth, probe_allowed).await } - crate::state::Provider::Codex if try_oauth => { - let (snapshot, limited) = crate::codex::poll(&client).await; - PollOutcome::done(snapshot, false, limited) + crate::state::Provider::Codex => { + let cooldown = codex_cooldown + .as_ref() + .map(|(scope, at, _)| (scope.as_str(), *at)); + match crate::codex::poll(&client, cooldown).await { + Some(mut result) => { + let count = codex_cooldown + .as_ref() + .filter(|(scope, _, _)| scope == &result.snapshot.scope) + .map_or(0, |(_, _, count)| *count); + if result.rate_limited { + let count = count.saturating_add(1); + let now = usage::now_ms(); + let deadline = + codex_retry_deadline(count, now, result.snapshot.retry_at); + result.snapshot.retry_at = Some(deadline); + codex_cooldown = + Some((result.snapshot.scope.clone(), deadline, count)); + } else if let Some(deadline) = result.snapshot.retry_at { + codex_cooldown = Some((result.snapshot.scope.clone(), deadline, 0)); + } else if codex_cooldown + .as_ref() + .is_some_and(|(scope, _, _)| scope == &result.snapshot.scope) + { + codex_cooldown = None; + } + let mut outcome = PollOutcome::done(result.snapshot, false, false); + outcome.transient = result.transient; + outcome + } + None => { + // Switching back clears last_usage. Show the pause instead of + // leaving the widget stuck on "Loading" until the next request. + let snapshot = + codex_cooldown.as_ref().and_then(|(scope, deadline, _)| { + let state = app.state::(); + let s = state.0.lock().unwrap(); + if s.last_usage + .as_ref() + .is_some_and(|last| &last.scope == scope) + { + return None; + } + let mut paused = crate::codex::error( + "Codex usage paused — waiting before retrying".into(), + Some(scope), + ); + paused.retry_at = Some(*deadline); + Some(paused) + }); + PollOutcome { + snapshot, + probed: false, + oauth_rate_limited: false, + transient: false, + } + } + } } - crate::state::Provider::Codex => PollOutcome { - snapshot: None, - probed: false, - oauth_rate_limited: false, - }, }; - // A provider change during the network request invalidates its result and backoff. + // A provider change invalidates the result; keep any account-specific Codex cooldown. if !app .state::() .0 @@ -92,7 +153,7 @@ pub fn spawn(app: AppHandle) { if outcome.probed { last_probe_ms = usage::now_ms(); } - if try_oauth { + if try_oauth && provider == crate::state::Provider::Claude { if outcome.oauth_rate_limited { rate_limited_polls += 1; next_oauth_ms = @@ -131,6 +192,8 @@ pub fn spawn(app: AppHandle) { } else { if let Some(previous) = same_account { retain_previous(&mut snapshot, previous); + snapshot.recovering = + codex_grace(&snapshot, outcome.transient, consecutive_failures); } s.last_usage = Some(snapshot.clone()); let log = app.state::(); @@ -163,7 +226,8 @@ fn publish_availability(app: &AppHandle, provider: crate::state::Provider, avail let updated = { let state = app.state::(); let mut s = state.0.lock().unwrap(); - s.available_providers.update(provider, available) + s.available_providers + .update(provider, available) .then(|| (s.layout, s.effective_scale())) }; if let Some((layout, scale)) = updated { @@ -180,6 +244,11 @@ fn retain_previous(snapshot: &mut UsageSnapshot, previous: &UsageSnapshot) { if snapshot.status == "ok" { usage::carry_missing_windows(snapshot, previous); } else { + snapshot.last_success_at = if previous.status == "ok" { + Some(previous.fetched_at) + } else { + previous.last_success_at + }; snapshot.windows = previous .windows .iter() @@ -192,8 +261,25 @@ fn retain_previous(snapshot: &mut UsageSnapshot, previous: &UsageSnapshot) { } } +fn codex_retry_deadline(count: u32, now: i64, server: Option) -> i64 { + now.saturating_add(backoff_ms(count, now)) + .max(server.unwrap_or(now)) +} + +fn codex_grace(snapshot: &UsageSnapshot, transient: bool, failures: u32) -> bool { + snapshot.provider == crate::state::Provider::Codex + && snapshot.status != "ok" + && transient + && snapshot.retry_at.is_none() + && failures < ERROR_GRACE_POLLS + && !snapshot.windows.is_empty() + && snapshot + .last_success_at + .is_some_and(|at| usage::now_ms().saturating_sub(at) < 5 * 60_000) +} + /// Sleep one poll interval; returns true when woken early by a manual -/// refresh, which should retry the usage endpoint immediately. +/// refresh. Codex still honors its account cooldown when woken early. async fn wait_or_refresh(notify: &Notify) -> bool { tokio::time::timeout(POLL_INTERVAL, notify.notified()) .await @@ -213,6 +299,7 @@ struct PollOutcome { probed: bool, /// Whether the usage endpoint answered HTTP 429, so the loop can back off. oauth_rate_limited: bool, + transient: bool, } impl PollOutcome { @@ -221,6 +308,7 @@ impl PollOutcome { snapshot: Some(snapshot), probed, oauth_rate_limited, + transient: false, } } } @@ -261,6 +349,7 @@ async fn poll_once(client: &reqwest::Client, try_oauth: bool, probe_allowed: boo snapshot: None, probed: false, oauth_rate_limited: false, + transient: false, }; } // A 429 says nothing about the token, so don't surface expiry over it. @@ -360,14 +449,51 @@ mod tests { let mut again = crate::codex::error("expired".into(), Some("codex:a")); retain_previous(&mut again, &restored); assert_eq!(again.windows.len(), 1); + assert_eq!(again.last_success_at, Some(previous.fetched_at)); + assert_eq!(restored.last_success_at, Some(previous.fetched_at)); let mut other = crate::codex::error("expired".into(), Some("codex:b")); retain_previous(&mut other, &previous); assert!(other.windows.is_empty()); + assert!(other.last_success_at.is_none()); let mut claude = UsageSnapshot::error("offline".into()); retain_previous(&mut claude, &previous); assert!(claude.windows.is_empty()); } + #[test] + fn codex_grace_requires_recent_same_account_data_and_only_transient_failures() { + let mut snapshot = crate::codex::error("temporary".into(), Some("codex:a")); + snapshot.last_success_at = Some(usage::now_ms()); + // No reading to display means errors must surface immediately. + assert!(!codex_grace(&snapshot, true, 1)); + snapshot.windows.push(usage::LimitWindow { + id: usage::ID_SESSION.into(), + label: "5h".into(), + utilization: 21.0, + reset_at: None, + stale: true, + window_seconds: Some(18000), + }); + assert!(codex_grace(&snapshot, true, 1)); + assert!(codex_grace(&snapshot, true, 2)); + assert!(!codex_grace(&snapshot, true, 3)); + assert!(!codex_grace(&snapshot, false, 1)); // auth / malformed data + snapshot.retry_at = Some(usage::now_ms() + 60_000); + assert!(!codex_grace(&snapshot, true, 1)); // server-requested pause + snapshot.retry_at = None; + snapshot.last_success_at = Some(usage::now_ms() - 5 * 60_000); + assert!(!codex_grace(&snapshot, true, 1)); + snapshot.last_success_at = None; // old saved error has no known age + assert!(!codex_grace(&snapshot, true, 1)); + } + + #[test] + fn codex_backoff_never_shortens_a_server_requested_pause() { + assert_eq!(codex_retry_deadline(1, 30_000, Some(900_000)), 900_000); + assert_eq!(codex_retry_deadline(1, 30_000, Some(31_000)), 150_000); + assert_eq!(codex_retry_deadline(1, 30_000, None), 150_000); + } + #[test] fn backoff_doubles_then_caps() { // Jitter is now_ms % 30_000; a multiple of 30s makes it zero. diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 3be8170..b7790dd 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -58,6 +58,15 @@ pub struct UsageSnapshot { #[serde(default)] pub windows: Vec, pub error: Option, + /// Timestamp of the retained successful reading, not the failed request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_success_at: Option, + /// Earliest next attempt after a server-requested pause or rate limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_at: Option, + /// Brief transient-failure grace period; values remain explicitly stale. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recovering: bool, } impl UsageSnapshot { @@ -70,6 +79,9 @@ impl UsageSnapshot { fetched_at: now_ms(), windows, error: None, + last_success_at: None, + retry_at: None, + recovering: false, } } @@ -82,6 +94,9 @@ impl UsageSnapshot { fetched_at: now_ms(), windows: Vec::new(), error: Some(message), + last_success_at: None, + retry_at: None, + recovering: false, } } } diff --git a/src/api.ts b/src/api.ts index d988698..14d40ce 100644 --- a/src/api.ts +++ b/src/api.ts @@ -39,6 +39,10 @@ export interface UsageSnapshot { source: "oauth" | "messages" | "codex-oauth" | null; /** unix epoch ms */ fetchedAt: number; + /** Age of the retained reading on a failed poll, in unix epoch ms. */ + lastSuccessAt?: number; + retryAt?: number; + recovering?: boolean; /** Every window the poll reported, in the API's order; empty on failure. */ windows: LimitWindow[]; error: string | null; diff --git a/src/main.ts b/src/main.ts index df3b636..e06d335 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import "./styles.css"; +import { usageStatus } from "./status"; import { getCurrentWindow } from "@tauri-apps/api/window"; import * as api from "./api"; import { UsageRenderer } from "./usage"; @@ -233,29 +234,19 @@ void api.onUpdatePhase((u) => { }); /* ---- status line: friendly guidance when polling fails ---- */ -// Kept terse: the widget can be very narrow, and the raw error sits in the -// element's tooltip for anyone who wants the details. -function friendlyError(err: string): string { - if (err.startsWith("Loading")) return err; - if (err.startsWith("Codex login expired")) return "Open Codex to refresh your login"; - if (err.startsWith("Codex login unavailable")) return "Sign in to Codex to start tracking"; - if (err.startsWith("Codex requires") || err.startsWith("Codex account identity")) return "Sign in to Codex with ChatGPT"; - if (err.startsWith("Codex usage access denied")) return "Codex usage access denied"; - if (err.startsWith("Codex returned no")) return "Codex usage limits unavailable"; - if (err.startsWith("Codex account changed")) return "Codex account changed — refreshing"; - if (err.includes("no Claude credentials")) return "Sign in to Claude Code to start tracking"; - if (err.startsWith("token expired")) return "Token expired — open Claude Code"; - return "Can't reach usage API — retrying"; -} +let statusSnapshot: api.UsageSnapshot | null = null; +// Only the displayed age/countdown ticks; this never triggers a network request. +setInterval(() => { if (statusSnapshot) renderStatus(statusSnapshot); }, 30_000); function renderStatus(s: api.UsageSnapshot) { + statusSnapshot = s; const failing = s.status !== "ok"; statusEl.hidden = !failing; // The content grid reserves a band for the bar while it's up (styles.css). root.classList.toggle("has-status", failing); if (!failing) return; - statusEl.textContent = friendlyError(s.error ?? ""); - statusEl.title = s.error ?? ""; + statusEl.textContent = usageStatus(s); + statusEl.title = `${usageStatus(s)}\n${s.error ?? ""}`; } /* ---- data wiring ---- */ diff --git a/src/status.test.ts b/src/status.test.ts new file mode 100644 index 0000000..382cedb --- /dev/null +++ b/src/status.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { UsageSnapshot } from "./api"; +import { friendlyError, usageStatus } from "./status"; + +const now = 1_800_000_000_000; +const failed: UsageSnapshot = { + provider: "codex", scope: "codex:a", status: "error", source: null, + fetchedAt: now, windows: [], error: "Cannot reach Codex usage — check your connection", + lastSuccessAt: now - 120_000, +}; + +describe("usage status", () => { + it.each([ + ["Codex login expired — open Codex", "Open Codex to refresh your login"], + ["Codex usage access denied for this account", "Codex usage access denied"], + ["Codex usage rate-limited — waiting before retrying", "Codex rate limit — waiting"], + ["Codex usage request timed out — retrying", "Codex request timed out — retrying"], + ["Codex usage: HTTP 503", "Codex service error (503) — retrying"], + ["Codex usage: HTTP 404", "Codex usage error (HTTP 404)"], + ["Codex usage returned an unreadable response", "Codex sent an invalid response"], + ["no Claude credentials", "Sign in to Claude Code to start tracking"], + ])("distinguishes %s", (error, expected) => { + expect(friendlyError(error)).toBe(expected); + }); + + it("keeps the age visible during grace, without making the reading appear fresh", () => { + expect(usageStatus({ ...failed, recovering: true }, now)).toBe("2m old · Updating…"); + expect(usageStatus({ ...failed, recovering: true }, now + 60_000)).toBe("3m old · Updating…"); + expect(usageStatus({ ...failed, recovering: true }, now + 180_000)) + .toBe("5m old · Can't reach usage API — retrying"); + }); + + it("shows rate-limit cooldowns and stops counting once due", () => { + const limited = { ...failed, error: "Codex usage rate-limited", retryAt: now + 90_000 }; + expect(usageStatus(limited, now)).toBe("2m old · Codex rate limit — waiting · retry in 2m"); + expect(usageStatus(limited, now + 90_000)).not.toContain("retry in"); + }); + + it("does not invent an age for legacy errors and clears status after recovery", () => { + expect(usageStatus({ ...failed, lastSuccessAt: undefined, recovering: true }, now)) + .toBe("Can't reach usage API — retrying"); + expect(usageStatus({ ...failed, status: "ok" }, now)).toBe(""); + }); +}); diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..e9d21d6 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,44 @@ +import type { UsageSnapshot } from "./api"; + +/** Terse widget guidance; the original safe error remains available in Settings. */ +export function friendlyError(err: string): string { + if (err.startsWith("Loading")) return err; + if (err.startsWith("Codex login expired")) return "Open Codex to refresh your login"; + if (err.startsWith("Codex login unavailable")) return "Sign in to Codex to start tracking"; + if (err.startsWith("Codex requires") || err.startsWith("Codex account identity")) return "Sign in to Codex with ChatGPT"; + if (err.startsWith("Codex usage access denied")) return "Codex usage access denied"; + if (err.startsWith("Codex returned no")) return "Codex usage limits unavailable"; + if (err.startsWith("Codex account changed")) return "Codex account changed — refreshing"; + if (err.startsWith("Codex usage paused")) return "Codex usage paused"; + if (err.startsWith("Codex usage rate-limited")) return "Codex rate limit — waiting"; + if (err.startsWith("Codex usage request timed out")) return "Codex request timed out — retrying"; + if (err.startsWith("Codex usage returned an unreadable")) return "Codex sent an invalid response"; + const http = /^Codex usage: HTTP (\d{3})$/.exec(err); + if (http) return Number(http[1]) >= 500 + ? `Codex service error (${http[1]}) — retrying` + : `Codex usage error (HTTP ${http[1]})`; + if (err.includes("no Claude credentials")) return "Sign in to Claude Code to start tracking"; + if (err.startsWith("token expired")) return "Token expired — open Claude Code"; + return "Can't reach usage API — retrying"; +} + +function age(ms: number): string { + const minutes = Math.max(0, Math.floor(ms / 60_000)); + if (minutes < 1) return "<1m"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ${minutes % 60}m`; + return `${Math.floor(hours / 24)}d ${hours % 24}h`; +} + +export function usageStatus(snapshot: UsageSnapshot, now = Date.now()): string { + if (snapshot.status === "ok") return ""; + const recent = snapshot.lastSuccessAt != null && now - snapshot.lastSuccessAt < 5 * 60_000; + const message = snapshot.recovering && recent ? "Updating…" : friendlyError(snapshot.error ?? ""); + const parts = [message]; + if (snapshot.retryAt != null && snapshot.retryAt > now) { + parts.push(`retry in ${Math.ceil((snapshot.retryAt - now) / 60_000)}m`); + } + if (snapshot.lastSuccessAt != null) parts.unshift(`${age(now - snapshot.lastSuccessAt)} old`); + return parts.join(" · "); +}