diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 0446c24fb1..be1daf483a 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -4,6 +4,8 @@ use crate::token_data::KnownPlan; use crate::token_data::PlanType; use crate::truncate::truncate_middle; use chrono::DateTime; +use chrono::Datelike; +use chrono::Local; use chrono::Utc; use codex_async_utils::CancelErr; use codex_protocol::ConversationId; @@ -286,28 +288,46 @@ impl std::fmt::Display for UsageLimitReachedError { } fn retry_suffix(resets_at: Option<&DateTime>) -> String { - if let Some(secs) = remaining_seconds(resets_at) { - let reset_duration = format_reset_duration(secs); - format!(" Try again in {reset_duration}.") + if let Some(resets_at) = resets_at { + let formatted = format_retry_timestamp(resets_at); + format!(" Try again at {formatted}.") } else { " Try again later.".to_string() } } fn retry_suffix_after_or(resets_at: Option<&DateTime>) -> String { - if let Some(secs) = remaining_seconds(resets_at) { - let reset_duration = format_reset_duration(secs); - format!(" or try again in {reset_duration}.") + if let Some(resets_at) = resets_at { + let formatted = format_retry_timestamp(resets_at); + format!(" or try again at {formatted}.") } else { " or try again later.".to_string() } } -fn remaining_seconds(resets_at: Option<&DateTime>) -> Option { - let resets_at = resets_at.cloned()?; - let now = now_for_retry(); - let secs = resets_at.signed_duration_since(now).num_seconds(); - Some(if secs <= 0 { 0 } else { secs as u64 }) +fn format_retry_timestamp(resets_at: &DateTime) -> String { + let local_reset = resets_at.with_timezone(&Local); + let local_now = now_for_retry().with_timezone(&Local); + if local_reset.date_naive() == local_now.date_naive() { + local_reset.format("%-I:%M %p").to_string() + } else { + let suffix = day_suffix(local_reset.day()); + local_reset + .format(&format!("%b %-d{suffix}, %Y %-I:%M %p")) + .to_string() + } +} + +fn day_suffix(day: u32) -> &'static str { + match day { + 11..=13 => "th", + _ => match day % 10 { + 1 => "st", + 2 => "nd", // codespell:ignore + 3 => "rd", + _ => "th", + }, + } } #[cfg(test)] @@ -326,36 +346,6 @@ fn now_for_retry() -> DateTime { Utc::now() } -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. @@ -572,15 +562,16 @@ mod tests { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let resets_at = base + ChronoDuration::hours(1); with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Team)), resets_at: Some(resets_at), rate_limits: Some(rate_limit_snapshot()), }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." + let expected = format!( + "You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}." ); + assert_eq!(err.to_string(), expected); }); } @@ -615,15 +606,16 @@ mod tests { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let resets_at = base + ChronoDuration::hours(1); with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Pro)), resets_at: Some(resets_at), rate_limits: Some(rate_limit_snapshot()), }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Visit chatgpt.com/codex/settings/usage to purchase more credits or try again in 1 hour." + let expected = format!( + "You've hit your usage limit. Visit chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." ); + assert_eq!(err.to_string(), expected); }); } @@ -632,15 +624,14 @@ mod tests { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let resets_at = base + ChronoDuration::minutes(5); with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { plan_type: None, resets_at: Some(resets_at), rate_limits: Some(rate_limit_snapshot()), }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 5 minutes." - ); + let expected = format!("You've hit your usage limit. Try again at {expected_time}."); + assert_eq!(err.to_string(), expected); }); } @@ -649,15 +640,16 @@ mod tests { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32); with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_at: Some(resets_at), rate_limits: Some(rate_limit_snapshot()), }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit chatgpt.com/codex/settings/usage to purchase more credits or try again in 3 hours 32 minutes." + let expected = format!( + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." ); + assert_eq!(err.to_string(), expected); }); } @@ -667,15 +659,14 @@ mod tests { let resets_at = base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5); with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { plan_type: None, resets_at: Some(resets_at), rate_limits: Some(rate_limit_snapshot()), }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." - ); + let expected = format!("You've hit your usage limit. Try again at {expected_time}."); + assert_eq!(err.to_string(), expected); }); } @@ -684,15 +675,14 @@ mod tests { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); let resets_at = base + ChronoDuration::seconds(30); with_now_override(base, move || { + let expected_time = format_retry_timestamp(&resets_at); let err = UsageLimitReachedError { plan_type: None, resets_at: Some(resets_at), rate_limits: Some(rate_limit_snapshot()), }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in less than a minute." - ); + let expected = format!("You've hit your usage limit. Try again at {expected_time}."); + assert_eq!(err.to_string(), expected); }); } }