Use int timestamps for rate limit reset_at (#5383)

The backend will be returning unix timestamps (seconds since epoch)
instead of RFC 3339 strings. This will make it more ergonomic for
developers to integrate against - no string parsing.
This commit is contained in:
Owen Lin
2025-10-20 12:26:46 -07:00
committed by GitHub
parent 8044b55335
commit c84fc83222
6 changed files with 33 additions and 28 deletions

View File

@@ -73,7 +73,7 @@ struct Error {
// Optional fields available on "usage_limit_reached" and "usage_not_included" errors // Optional fields available on "usage_limit_reached" and "usage_not_included" errors
plan_type: Option<PlanType>, plan_type: Option<PlanType>,
resets_at: Option<String>, resets_at: Option<i64>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -425,9 +425,7 @@ impl ModelClient {
.or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type));
let resets_at = error let resets_at = error
.resets_at .resets_at
.as_deref() .and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0));
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|dt| dt.with_timezone(&Utc));
let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type, plan_type,
resets_at, resets_at,
@@ -636,10 +634,7 @@ fn parse_rate_limit_window(
used_percent.and_then(|used_percent| { used_percent.and_then(|used_percent| {
let window_minutes = parse_header_i64(headers, window_minutes_header); let window_minutes = parse_header_i64(headers, window_minutes_header);
let resets_at = parse_header_str(headers, resets_header) let resets_at = parse_header_i64(headers, resets_header);
.map(str::trim)
.filter(|value| !value.is_empty())
.map(std::string::ToString::to_string);
let has_data = used_percent != 0.0 let has_data = used_percent != 0.0
|| window_minutes.is_some_and(|minutes| minutes != 0) || window_minutes.is_some_and(|minutes| minutes != 0)
@@ -1426,7 +1421,8 @@ mod tests {
use crate::token_data::KnownPlan; use crate::token_data::KnownPlan;
use crate::token_data::PlanType; use crate::token_data::PlanType;
let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":"2024-01-01T00:00:00Z"}}"#; let json =
r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":1704067200}}"#;
let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema");
assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro)));
@@ -1439,7 +1435,8 @@ mod tests {
fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() { fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() {
use crate::token_data::PlanType; use crate::token_data::PlanType;
let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":"2024-01-01T00:01:00Z"}}"#; let json =
r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":1704067260}}"#;
let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); 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_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip");

View File

@@ -421,16 +421,24 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
fn rate_limit_snapshot() -> RateLimitSnapshot { fn rate_limit_snapshot() -> RateLimitSnapshot {
let primary_reset_at = Utc
.with_ymd_and_hms(2024, 1, 1, 1, 0, 0)
.unwrap()
.timestamp();
let secondary_reset_at = Utc
.with_ymd_and_hms(2024, 1, 1, 2, 0, 0)
.unwrap()
.timestamp();
RateLimitSnapshot { RateLimitSnapshot {
primary: Some(RateLimitWindow { primary: Some(RateLimitWindow {
used_percent: 50.0, used_percent: 50.0,
window_minutes: Some(60), window_minutes: Some(60),
resets_at: Some("2024-01-01T01:00:00Z".to_string()), resets_at: Some(primary_reset_at),
}), }),
secondary: Some(RateLimitWindow { secondary: Some(RateLimitWindow {
used_percent: 30.0, used_percent: 30.0,
window_minutes: Some(120), window_minutes: Some(120),
resets_at: Some("2024-01-01T02:00:00Z".to_string()), resets_at: Some(secondary_reset_at),
}), }),
} }
} }

View File

@@ -768,8 +768,8 @@ async fn token_count_includes_rate_limits_snapshot() {
.insert_header("x-codex-secondary-used-percent", "40.0") .insert_header("x-codex-secondary-used-percent", "40.0")
.insert_header("x-codex-primary-window-minutes", "10") .insert_header("x-codex-primary-window-minutes", "10")
.insert_header("x-codex-secondary-window-minutes", "60") .insert_header("x-codex-secondary-window-minutes", "60")
.insert_header("x-codex-primary-reset-at", "2024-01-01T00:30:00Z") .insert_header("x-codex-primary-reset-at", "1704069000")
.insert_header("x-codex-secondary-reset-at", "2024-01-01T02:00:00Z") .insert_header("x-codex-secondary-reset-at", "1704074400")
.set_body_raw(sse_body, "text/event-stream"); .set_body_raw(sse_body, "text/event-stream");
Mock::given(method("POST")) Mock::given(method("POST"))
@@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() {
"primary": { "primary": {
"used_percent": 12.5, "used_percent": 12.5,
"window_minutes": 10, "window_minutes": 10,
"resets_at": "2024-01-01T00:30:00Z" "resets_at": 1704069000
}, },
"secondary": { "secondary": {
"used_percent": 40.0, "used_percent": 40.0,
"window_minutes": 60, "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": { "primary": {
"used_percent": 12.5, "used_percent": 12.5,
"window_minutes": 10, "window_minutes": 10,
"resets_at": "2024-01-01T00:30:00Z" "resets_at": 1704069000
}, },
"secondary": { "secondary": {
"used_percent": 40.0, "used_percent": 40.0,
"window_minutes": 60, "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 final_snapshot
.primary .primary
.as_ref() .as_ref()
.and_then(|window| window.resets_at.as_deref()), .and_then(|window| window.resets_at),
Some("2024-01-01T00:30:00Z") Some(1704069000)
); );
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; 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": { "error": {
"type": "usage_limit_reached", "type": "usage_limit_reached",
"message": "limit reached", "message": "limit reached",
"resets_at": "2024-01-01T00:00:42Z", "resets_at": 1704067242,
"plan_type": "pro" "plan_type": "pro"
} }
})); }));

View File

@@ -658,9 +658,9 @@ pub struct RateLimitWindow {
/// Rolling window duration, in minutes. /// Rolling window duration, in minutes.
#[ts(type = "number | null")] #[ts(type = "number | null")]
pub window_minutes: Option<i64>, pub window_minutes: Option<i64>,
/// Timestamp (RFC3339) when the window resets. /// Unix timestamp (seconds since epoch) when the window resets.
#[ts(type = "string | null")] #[ts(type = "number | null")]
pub resets_at: Option<String>, pub resets_at: Option<i64>,
} }
// Includes prompts, tools and space to call compact. // Includes prompts, tools and space to call compact.

View File

@@ -3,6 +3,7 @@ use crate::chatwidget::get_limits_duration;
use super::helpers::format_reset_timestamp; use super::helpers::format_reset_timestamp;
use chrono::DateTime; use chrono::DateTime;
use chrono::Local; use chrono::Local;
use chrono::Utc;
use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::RateLimitWindow; use codex_core::protocol::RateLimitWindow;
@@ -34,8 +35,7 @@ impl RateLimitWindowDisplay {
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self { fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
let resets_at = window let resets_at = window
.resets_at .resets_at
.as_deref() .and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0))
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|dt| dt.with_timezone(&Local)) .map(|dt| dt.with_timezone(&Local))
.map(|dt| format_reset_timestamp(dt, captured_at)); .map(|dt| format_reset_timestamp(dt, captured_at));

View File

@@ -62,10 +62,10 @@ fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
.collect() .collect()
} }
fn reset_at_from(captured_at: &chrono::DateTime<chrono::Local>, seconds: i64) -> String { fn reset_at_from(captured_at: &chrono::DateTime<chrono::Local>, seconds: i64) -> i64 {
(*captured_at + ChronoDuration::seconds(seconds)) (*captured_at + ChronoDuration::seconds(seconds))
.with_timezone(&Utc) .with_timezone(&Utc)
.to_rfc3339() .timestamp()
} }
#[test] #[test]