diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ecf1ae7125..14a363986b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,6 +1,5 @@ use std::io::BufRead; use std::path::Path; -use std::sync::OnceLock; use std::time::Duration; use bytes::Bytes; @@ -8,7 +7,6 @@ use codex_login::AuthManager; use codex_login::AuthMode; use eventsource_stream::Eventsource; use futures::prelude::*; -use regex_lite::Regex; use reqwest::StatusCode; use serde::Deserialize; use serde::Serialize; @@ -54,8 +52,11 @@ struct ErrorResponse { #[derive(Debug, Deserialize)] struct Error { r#type: Option, - code: Option, message: Option, + + // Optional fields available on "usage_limit_reached" and "usage_not_included" errors + plan_type: Option, + resets_in_seconds: Option, } #[derive(Debug, Clone)] @@ -303,19 +304,20 @@ impl ModelClient { if status == StatusCode::TOO_MANY_REQUESTS { let body = res.json::().await.ok(); - if let Some(ErrorResponse { - error: - Error { - r#type: Some(error_type), - .. - }, - }) = body - { - if error_type == "usage_limit_reached" { + if let Some(ErrorResponse { error }) = body { + if error.r#type.as_deref() == Some("usage_limit_reached") { + // Prefer the plan_type provided in the error message if present + // because it's more up to date than the one encoded in the auth + // token. + let plan_type = error + .plan_type + .or_else(|| auth.and_then(|a| a.get_plan_type())); + let resets_in_seconds = error.resets_in_seconds; return Err(CodexErr::UsageLimitReached(UsageLimitReachedError { - plan_type: auth.and_then(|a| a.get_plan_type()), + plan_type, + resets_in_seconds, })); - } else if error_type == "usage_not_included" { + } else if error.r#type.as_deref() == Some("usage_not_included") { return Err(CodexErr::UsageNotIncluded); } } @@ -563,9 +565,8 @@ async fn process_sse( if let Some(error) = error { match serde_json::from_value::(error.clone()) { Ok(error) => { - let delay = try_parse_retry_after(&error); let message = error.message.unwrap_or_default(); - response_error = Some(CodexErr::Stream(message, delay)); + response_error = Some(CodexErr::Stream(message, None)); } Err(e) => { debug!("failed to parse ErrorResponse: {e}"); @@ -653,40 +654,6 @@ async fn stream_from_fixture( Ok(ResponseStream { rx_event }) } -fn rate_limit_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - - #[expect(clippy::unwrap_used)] - RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap()) -} - -fn try_parse_retry_after(err: &Error) -> Option { - if err.code != Some("rate_limit_exceeded".to_string()) { - return None; - } - - // parse the Please try again in 1.898s format using regex - let re = rate_limit_regex(); - if let Some(message) = &err.message - && let Some(captures) = re.captures(message) - { - let seconds = captures.get(1); - let unit = captures.get(2); - - if let (Some(value), Some(unit)) = (seconds, unit) { - let value = value.as_str().parse::().ok()?; - let unit = unit.as_str(); - - if unit == "s" { - return Some(Duration::from_secs_f64(value)); - } else if unit == "ms" { - return Some(Duration::from_millis(value as u64)); - } - } - } - None -} - #[cfg(test)] mod tests { use super::*; @@ -907,7 +874,7 @@ mod tests { msg, "Rate limit reached for gpt-5 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more." ); - assert_eq!(*delay, Some(Duration::from_secs_f64(11.054))); + assert_eq!(*delay, None); } other => panic!("unexpected second event: {other:?}"), } @@ -1011,27 +978,4 @@ mod tests { ); } } - - #[test] - fn test_try_parse_retry_after() { - let err = Error { - r#type: None, - message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), - code: Some("rate_limit_exceeded".to_string()), - }; - - let delay = try_parse_retry_after(&err); - assert_eq!(delay, Some(Duration::from_millis(28))); - } - - #[test] - fn test_try_parse_retry_after_no_delay() { - let err = Error { - r#type: None, - message: Some("Rate limit reached for gpt-5 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), - code: Some("rate_limit_exceeded".to_string()), - }; - let delay = try_parse_retry_after(&err); - assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); - } } diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 356c23011c..00e50239d4 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -128,27 +128,70 @@ pub enum CodexErr { #[derive(Debug)] pub struct UsageLimitReachedError { pub plan_type: Option, + pub resets_in_seconds: Option, } impl std::fmt::Display for UsageLimitReachedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Base message differs slightly for legacy ChatGPT Plus plan users. if let Some(plan_type) = &self.plan_type && plan_type == "plus" { write!( f, - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)." + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again" )?; + if let Some(secs) = self.resets_in_seconds { + let reset_duration = format_reset_duration(secs); + write!(f, " in {reset_duration}.")?; + } else { + write!(f, " later.")?; + } } else { - write!( - f, - "You've hit your usage limit. Limits reset every 5h and every week." - )?; + write!(f, "You've hit your usage limit.")?; + + if let Some(secs) = self.resets_in_seconds { + let reset_duration = format_reset_duration(secs); + write!(f, " Try again in {reset_duration}.")?; + } else { + write!(f, " Try again later.")?; + } } + Ok(()) } } +fn format_reset_duration(total_secs: u64) -> String { + let days = total_secs / 86_400; + let hours = (total_secs % 86_400) / 3_600; + let minutes = (total_secs % 3_600) / 60; + + let mut parts: Vec = Vec::new(); + if days > 0 { + let unit = if days == 1 { "day" } else { "days" }; + parts.push(format!("{} {}", days, unit)); + } + if hours > 0 { + let unit = if hours == 1 { "hour" } else { "hours" }; + parts.push(format!("{} {}", hours, unit)); + } + if minutes > 0 { + let unit = if minutes == 1 { "minute" } else { "minutes" }; + parts.push(format!("{} {}", minutes, unit)); + } + + if parts.is_empty() { + return "less than a minute".to_string(); + } + + match parts.len() { + 1 => parts[0].clone(), + 2 => format!("{} {}", parts[0], parts[1]), + _ => format!("{} {} {}", parts[0], parts[1], parts[2]), + } +} + #[derive(Debug)] pub struct EnvVarError { /// Name of the environment variable that is missing. @@ -193,19 +236,23 @@ mod tests { fn usage_limit_reached_error_formats_plus_plan() { let err = UsageLimitReachedError { plan_type: Some("plus".to_string()), + resets_in_seconds: None, }; assert_eq!( err.to_string(), - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), or wait for limits to reset (every 5h and every week.)." + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again later." ); } #[test] fn usage_limit_reached_error_formats_default_when_none() { - let err = UsageLimitReachedError { plan_type: None }; + let err = UsageLimitReachedError { + plan_type: None, + resets_in_seconds: None, + }; assert_eq!( err.to_string(), - "You've hit your usage limit. Limits reset every 5h and every week." + "You've hit your usage limit. Try again later." ); } @@ -213,10 +260,59 @@ mod tests { fn usage_limit_reached_error_formats_default_for_other_plans() { let err = UsageLimitReachedError { plan_type: Some("pro".to_string()), + resets_in_seconds: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again later." + ); + } + + #[test] + fn usage_limit_reached_includes_minutes_when_available() { + let err = UsageLimitReachedError { + plan_type: None, + resets_in_seconds: Some(5 * 60), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 5 minutes." + ); + } + + #[test] + fn usage_limit_reached_includes_hours_and_minutes() { + let err = UsageLimitReachedError { + plan_type: Some("plus".to_string()), + resets_in_seconds: Some(3 * 3600 + 32 * 60), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." + ); + } + + #[test] + fn usage_limit_reached_includes_days_hours_minutes() { + let err = UsageLimitReachedError { + plan_type: None, + resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." + ); + } + + #[test] + fn usage_limit_reached_less_than_minute() { + let err = UsageLimitReachedError { + plan_type: None, + resets_in_seconds: Some(30), }; assert_eq!( err.to_string(), - "You've hit your usage limit. Limits reset every 5h and every week." + "You've hit your usage limit. Try again in less than a minute." ); } }