From 211b681499ca5e6a55cf111edb5b901fc4b2d2d4 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Mon, 20 Oct 2025 09:16:27 -0700 Subject: [PATCH] Use Unix seconds for rate-limit reset timestamps --- codex-rs/core/src/client.rs | 37 +++++++++++++++++++++----- codex-rs/core/src/error.rs | 12 +++++++-- codex-rs/core/tests/suite/client.rs | 14 +++++----- codex-rs/protocol/src/protocol.rs | 6 ++--- codex-rs/tui/src/status/rate_limits.rs | 5 ++-- codex-rs/tui/src/status/tests.rs | 4 +-- 6 files changed, 56 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b215106d2b..da99b0b94c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -53,6 +53,7 @@ use crate::state::TaskKind; use crate::token_data::PlanType; use crate::util::backoff; use chrono::DateTime; +use chrono::TimeZone; use chrono::Utc; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; @@ -76,6 +77,19 @@ struct Error { resets_at: Option, } +fn parse_reset_timestamp(value: Option<&str>) -> Option> { + value.and_then(|raw| { + raw.parse::() + .ok() + .and_then(|secs| Utc.timestamp_opt(secs, 0).single()) + .or_else(|| { + DateTime::parse_from_rfc3339(raw) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + }) + }) +} + #[derive(Debug, Clone)] pub struct ModelClient { config: Arc, @@ -421,11 +435,7 @@ impl ModelClient { let plan_type = error .plan_type .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); - let resets_at = error - .resets_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|dt| dt.with_timezone(&Utc)); + let resets_at = parse_reset_timestamp(error.resets_at.as_deref()); let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, resets_at, @@ -637,7 +647,13 @@ fn parse_rate_limit_window( let resets_at = parse_header_str(headers, resets_header) .map(str::trim) .filter(|value| !value.is_empty()) - .map(std::string::ToString::to_string); + .and_then(|value| { + value.parse::().ok().or_else(|| { + DateTime::parse_from_rfc3339(value) + .map(|dt| dt.timestamp()) + .ok() + }) + }); let has_data = used_percent != 0.0 || window_minutes.is_some_and(|minutes| minutes != 0) @@ -1428,6 +1444,10 @@ mod tests { let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); + assert_eq!( + resp.error.resets_at.as_deref(), + Some("2024-01-01T00:00:00Z") + ); let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type"); assert_eq!(plan_json, "\"pro\""); @@ -1441,8 +1461,13 @@ mod tests { let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); + assert_eq!( + resp.error.resets_at.as_deref(), + Some("2024-01-01T00:01:00Z") + ); let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type"); assert_eq!(plan_json, "\"vip\""); } + } diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 5597ffd431..ed2a752d66 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -425,12 +425,20 @@ mod tests { primary: Some(RateLimitWindow { used_percent: 50.0, window_minutes: Some(60), - resets_at: Some("2024-01-01T01:00:00Z".to_string()), + resets_at: Some( + Utc.with_ymd_and_hms(2024, 1, 1, 1, 0, 0) + .unwrap() + .timestamp(), + ), }), secondary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), - resets_at: Some("2024-01-01T02:00:00Z".to_string()), + resets_at: Some( + Utc.with_ymd_and_hms(2024, 1, 1, 2, 0, 0) + .unwrap() + .timestamp(), + ), }), } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 817f2c2a08..5fa713a8a3 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_at": "2024-01-01T00:30:00Z" + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_at": "2024-01-01T02:00:00Z" + "resets_at": 1704074400 } } }) @@ -865,12 +865,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_at": "2024-01-01T00:30:00Z" + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_at": "2024-01-01T02:00:00Z" + "resets_at": 1704074400 } } }) @@ -893,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() { final_snapshot .primary .as_ref() - .and_then(|window| window.resets_at.as_deref()), - Some("2024-01-01T00:30:00Z") + .and_then(|window| window.resets_at), + Some(1704069000) ); wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; @@ -915,7 +915,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "error": { "type": "usage_limit_reached", "message": "limit reached", - "resets_at": "2024-01-01T00:00:42Z", + "resets_at": 1704067242, "plan_type": "pro" } })); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5429e4d402..e56e54af92 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -644,9 +644,9 @@ pub struct RateLimitWindow { /// Rolling window duration, in minutes. #[ts(type = "number | null")] pub window_minutes: Option, - /// Timestamp (RFC3339) when the window resets. - #[ts(type = "string | null")] - pub resets_at: Option, + /// Timestamp (Unix seconds) when the window resets. + #[ts(type = "number | null")] + pub resets_at: Option, } // Includes prompts, tools and space to call compact. diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index f6a9c06aab..4e4f16b9ba 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -3,6 +3,8 @@ use crate::chatwidget::get_limits_duration; use super::helpers::format_reset_timestamp; use chrono::DateTime; use chrono::Local; +use chrono::TimeZone; +use chrono::Utc; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; @@ -34,8 +36,7 @@ impl RateLimitWindowDisplay { fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { let resets_at = window .resets_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .and_then(|value| Utc.timestamp_opt(value, 0).single()) .map(|dt| dt.with_timezone(&Local)) .map(|dt| format_reset_timestamp(dt, captured_at)); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 5cb730c905..b8c945eeec 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -62,10 +62,10 @@ fn sanitize_directory(lines: Vec) -> Vec { .collect() } -fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> String { +fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> i64 { (*captured_at + ChronoDuration::seconds(seconds)) .with_timezone(&Utc) - .to_rfc3339() + .timestamp() } #[test]