From 62ed5907f9fcdabcd87c94aeca3d293e177d3aed Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 7 Aug 2025 09:46:13 -0700 Subject: [PATCH] Better usage errors (#1941) image --- codex-rs/core/src/client.rs | 39 ++++++++++++++++++++++++++++++------- codex-rs/core/src/codex.rs | 1 + codex-rs/core/src/error.rs | 6 ++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ed05fb5d..3a709aad 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -40,6 +40,16 @@ use crate::protocol::TokenUsage; use crate::util::backoff; use std::sync::Arc; +#[derive(Debug, Deserialize)] +struct ErrorResponse { + error: Error, +} + +#[derive(Debug, Deserialize)] +struct Error { + code: String, +} + #[derive(Clone)] pub struct ModelClient { config: Arc, @@ -225,6 +235,14 @@ impl ModelClient { } Ok(res) => { let status = res.status(); + + // Pull out Retry‑After header if present. + let retry_after_secs = res + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); + // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx // errors. When we bubble early with only the HTTP status the caller sees an opaque // "unexpected status 400 Bad Request" which makes debugging nearly impossible. @@ -238,17 +256,24 @@ impl ModelClient { return Err(CodexErr::UnexpectedStatus(status, body)); } + if status == StatusCode::TOO_MANY_REQUESTS { + let body = res.json::().await.ok(); + if let Some(ErrorResponse { + error: Error { code, .. }, + }) = body + { + if code == "usage_limit_reached" { + return Err(CodexErr::UsageLimitReached); + } else if code == "usage_not_included" { + return Err(CodexErr::UsageNotIncluded); + } + } + } + if attempt > max_retries { return Err(CodexErr::RetryLimit(status)); } - // Pull out Retry‑After header if present. - let retry_after_secs = res - .headers() - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()); - let delay = retry_after_secs .map(|s| Duration::from_millis(s * 1_000)) .unwrap_or_else(|| backoff(attempt)); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index eb1bc4f9..aaef73de 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1290,6 +1290,7 @@ async fn run_turn( Ok(output) => return Ok(output), Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), + Err(e @ (CodexErr::UsageLimitReached | CodexErr::UsageNotIncluded)) => return Err(e), Err(e) => { // Use the configured provider-specific stream retry budget. let max_retries = sess.client.get_provider().stream_max_retries(); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 537f4a03..1f283346 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -62,6 +62,12 @@ pub enum CodexErr { #[error("unexpected status {0}: {1}")] UnexpectedStatus(StatusCode, String), + #[error("Usage limit has been reached")] + UsageLimitReached, + + #[error("Usage not included with the plan")] + UsageNotIncluded, + /// Retry limit exceeded. #[error("exceeded retry limit, last status: {0}")] RetryLimit(StatusCode),