Skip to content

Commit 0e08dd6

Browse files
authored
fix: switch rate limit reset handling to timestamps (#5304)
This change ensures that we store the absolute time instead of relative offsets of when the primary and secondary rate limits will reset. Previously these got recalculated relative to current time, which leads to the displayed reset times to change over time, including after doing a codex resume. For previously changed sessions, this will cause the reset times to not show due to this being a breaking change: <img width="524" height="55" alt="Screenshot 2025-10-17 at 5 14 18 PM" src="https://github.com/user-attachments/assets/53ebd43e-da25-4fef-9c47-94a529d40265" /> Fixes #4761
1 parent 41900e9 commit 0e08dd6

File tree

7 files changed

+191
-115
lines changed

7 files changed

+191
-115
lines changed

codex-rs/clippy.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ disallowed-methods = [
77
{ path = "ratatui::style::Stylize::black", reason = "Avoid hardcoding black; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." },
88
{ path = "ratatui::style::Stylize::yellow", reason = "Avoid yellow; prefer other colors in `tui/styles.md`." },
99
]
10+
11+
# Increase the size threshold for result_large_err to accommodate
12+
# richer error variants.
13+
large-error-threshold = 256

codex-rs/core/src/client.rs

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ use crate::protocol::TokenUsage;
5252
use crate::state::TaskKind;
5353
use crate::token_data::PlanType;
5454
use crate::util::backoff;
55+
use chrono::DateTime;
56+
use chrono::Utc;
5557
use codex_otel::otel_event_manager::OtelEventManager;
5658
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
5759
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -71,7 +73,7 @@ struct Error {
7173

7274
// Optional fields available on "usage_limit_reached" and "usage_not_included" errors
7375
plan_type: Option<PlanType>,
74-
resets_in_seconds: Option<u64>,
76+
resets_at: Option<String>,
7577
}
7678

7779
#[derive(Debug, Clone)]
@@ -419,10 +421,14 @@ impl ModelClient {
419421
let plan_type = error
420422
.plan_type
421423
.or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type));
422-
let resets_in_seconds = error.resets_in_seconds;
424+
let resets_at = error
425+
.resets_at
426+
.as_deref()
427+
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
428+
.map(|dt| dt.with_timezone(&Utc));
423429
let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError {
424430
plan_type,
425-
resets_in_seconds,
431+
resets_at,
426432
rate_limits: rate_limit_snapshot,
427433
});
428434
return Err(StreamAttemptError::Fatal(codex_err));
@@ -605,14 +611,14 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
605611
headers,
606612
"x-codex-primary-used-percent",
607613
"x-codex-primary-window-minutes",
608-
"x-codex-primary-reset-after-seconds",
614+
"x-codex-primary-reset-at",
609615
);
610616

611617
let secondary = parse_rate_limit_window(
612618
headers,
613619
"x-codex-secondary-used-percent",
614620
"x-codex-secondary-window-minutes",
615-
"x-codex-secondary-reset-after-seconds",
621+
"x-codex-secondary-reset-at",
616622
);
617623

618624
Some(RateLimitSnapshot { primary, secondary })
@@ -628,16 +634,19 @@ fn parse_rate_limit_window(
628634

629635
used_percent.and_then(|used_percent| {
630636
let window_minutes = parse_header_u64(headers, window_minutes_header);
631-
let resets_in_seconds = parse_header_u64(headers, resets_header);
637+
let resets_at = parse_header_str(headers, resets_header)
638+
.map(str::trim)
639+
.filter(|value| !value.is_empty())
640+
.map(std::string::ToString::to_string);
632641

633642
let has_data = used_percent != 0.0
634643
|| window_minutes.is_some_and(|minutes| minutes != 0)
635-
|| resets_in_seconds.is_some_and(|seconds| seconds != 0);
644+
|| resets_at.is_some();
636645

637646
has_data.then_some(RateLimitWindow {
638647
used_percent,
639648
window_minutes,
640-
resets_in_seconds,
649+
resets_at,
641650
})
642651
})
643652
}
@@ -1390,7 +1399,7 @@ mod tests {
13901399
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()),
13911400
code: Some("rate_limit_exceeded".to_string()),
13921401
plan_type: None,
1393-
resets_in_seconds: None
1402+
resets_at: None
13941403
};
13951404

13961405
let delay = try_parse_retry_after(&err);
@@ -1404,20 +1413,19 @@ mod tests {
14041413
message: Some("Rate limit reached for gpt-5 in organization <ORG> 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()),
14051414
code: Some("rate_limit_exceeded".to_string()),
14061415
plan_type: None,
1407-
resets_in_seconds: None
1416+
resets_at: None
14081417
};
14091418
let delay = try_parse_retry_after(&err);
14101419
assert_eq!(delay, Some(Duration::from_secs_f64(1.898)));
14111420
}
14121421

14131422
#[test]
1414-
fn error_response_deserializes_old_schema_known_plan_type_and_serializes_back() {
1423+
fn error_response_deserializes_schema_known_plan_type_and_serializes_back() {
14151424
use crate::token_data::KnownPlan;
14161425
use crate::token_data::PlanType;
14171426

1418-
let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_in_seconds":3600}}"#;
1419-
let resp: ErrorResponse =
1420-
serde_json::from_str(json).expect("should deserialize old schema");
1427+
let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":"2024-01-01T00:00:00Z"}}"#;
1428+
let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema");
14211429

14221430
assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro)));
14231431

@@ -1426,13 +1434,11 @@ mod tests {
14261434
}
14271435

14281436
#[test]
1429-
fn error_response_deserializes_old_schema_unknown_plan_type_and_serializes_back() {
1437+
fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() {
14301438
use crate::token_data::PlanType;
14311439

1432-
let json =
1433-
r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_in_seconds":60}}"#;
1434-
let resp: ErrorResponse =
1435-
serde_json::from_str(json).expect("should deserialize old schema");
1440+
let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":"2024-01-01T00:01:00Z"}}"#;
1441+
let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema");
14361442

14371443
assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip");
14381444

0 commit comments

Comments
 (0)