diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6456ffcc8e..b002cdf630 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -332,6 +332,8 @@ impl ModelClient { if status == StatusCode::TOO_MANY_REQUESTS { let rate_limit_snapshot = parse_rate_limit_snapshot(res.headers()); + let header_reset_hint = + rate_limit_snapshot.as_ref().and_then(rate_limit_reset_hint); let body = res.json::().await.ok(); if let Some(ErrorResponse { error }) = body { if error.r#type.as_deref() == Some("usage_limit_reached") { @@ -341,10 +343,9 @@ impl ModelClient { let plan_type = error .plan_type .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); - let resets_in_seconds = error.resets_in_seconds; return Err(CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, - resets_in_seconds, + resets_in_seconds: header_reset_hint, rate_limits: rate_limit_snapshot, })); } else if error.r#type.as_deref() == Some("usage_not_included") { @@ -518,6 +519,21 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { Some(RateLimitSnapshot { primary, secondary }) } +fn rate_limit_reset_hint(snapshot: &RateLimitSnapshot) -> Option { + [snapshot.primary.as_ref(), snapshot.secondary.as_ref()] + .into_iter() + .flatten() + .filter(|window| window.used_percent >= 100.0) + .filter_map(|window| { + window.resets_in_seconds.or_else(|| { + window + .window_minutes + .map(|minutes| minutes.saturating_mul(60)) + }) + }) + .max() +} + fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { parse_header_str(headers, name)? .parse::() diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 867accd78d..b9646c2ca6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -907,6 +907,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { .insert_header("x-codex-primary-over-secondary-limit-percent", "95.0") .insert_header("x-codex-primary-window-minutes", "15") .insert_header("x-codex-secondary-window-minutes", "60") + .insert_header("x-codex-primary-reset-after-seconds", "900") + .insert_header("x-codex-secondary-reset-after-seconds", "3600") .set_body_json(json!({ "error": { "type": "usage_limit_reached", @@ -931,12 +933,12 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "primary": { "used_percent": 100.0, "window_minutes": 15, - "resets_in_seconds": null + "resets_in_seconds": 900 }, "secondary": { "used_percent": 87.5, "window_minutes": 60, - "resets_in_seconds": null + "resets_in_seconds": 3600 } }); @@ -972,6 +974,67 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "unexpected error message for submission {submission_id}: {}", error_event.message ); + assert!( + error_event.message.contains("15 minutes"), + "expected reset hint in error message for submission {submission_id}: {}", + error_event.message + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn usage_limit_error_prefers_longer_reset_window() -> anyhow::Result<()> { + let server = MockServer::start().await; + + let response = ResponseTemplate::new(429) + .insert_header("x-codex-primary-used-percent", "100.0") + .insert_header("x-codex-secondary-used-percent", "100.0") + .insert_header("x-codex-primary-window-minutes", "10") + .insert_header("x-codex-secondary-window-minutes", "60") + .insert_header("x-codex-primary-reset-after-seconds", "600") + .insert_header("x-codex-secondary-reset-after-seconds", "7200") + .set_body_json(json!({ + "error": { + "type": "usage_limit_reached", + "message": "limit reached", + "resets_in_seconds": 5, + "plan_type": "pro" + } + })); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + + let mut builder = test_codex(); + let codex_fixture = builder.build(&server).await?; + let codex = codex_fixture.codex.clone(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .expect("submission should succeed while emitting usage limit error events"); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await; + + let error_event = wait_for_event(&codex, |msg| matches!(msg, EventMsg::Error(_))).await; + let EventMsg::Error(error_event) = error_event else { + unreachable!(); + }; + + assert!( + error_event.message.contains("2 hours"), + "expected longer reset hint in error message: {}", + error_event.message + ); Ok(()) }