From 0c647bc56690ab2d727564e0c47374f2b9e21e54 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 6 Nov 2025 17:12:01 -0600 Subject: [PATCH] Don't retry "insufficient_quota" errors (#6340) This PR makes an "insufficient quota" error fatal so we don't attempt to retry it multiple times in the agent loop. We have multiple bug reports from users about intermittent retry behaviors, and this could explain some of them. With this change, we'll eliminate the retries and surface a clear error message. The PR is a nearly identical copy of [this PR](https://github.com/openai/codex/pull/4837) contributed by @abimaelmartell. The original PR has gone stale. Rather than wait for the contributor to resolve merge conflicts, I wanted to get this change in. --- codex-rs/core/src/client.rs | 43 ++++++++++++ codex-rs/core/src/codex.rs | 1 + codex-rs/core/src/error.rs | 3 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/quota_exceeded.rs | 75 +++++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 codex-rs/core/tests/suite/quota_exceeded.rs diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8cd1bca5..3a0bcb9b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -447,6 +447,8 @@ impl ModelClient { return Err(StreamAttemptError::Fatal(codex_err)); } else if error.r#type.as_deref() == Some("usage_not_included") { return Err(StreamAttemptError::Fatal(CodexErr::UsageNotIncluded)); + } else if is_quota_exceeded_error(&error) { + return Err(StreamAttemptError::Fatal(CodexErr::QuotaExceeded)); } } } @@ -844,6 +846,8 @@ async fn process_sse( Ok(error) => { if is_context_window_error(&error) { response_error = Some(CodexErr::ContextWindowExceeded); + } else if is_quota_exceeded_error(&error) { + response_error = Some(CodexErr::QuotaExceeded); } else { let delay = try_parse_retry_after(&error); let message = error.message.clone().unwrap_or_default(); @@ -975,6 +979,10 @@ fn is_context_window_error(error: &Error) -> bool { error.code.as_deref() == Some("context_length_exceeded") } +fn is_quota_exceeded_error(error: &Error) -> bool { + error.code.as_deref() == Some("insufficient_quota") +} + #[cfg(test)] mod tests { use super::*; @@ -1307,6 +1315,41 @@ mod tests { } } + #[tokio::test] + async fn quota_exceeded_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + let provider = ModelProviderInfo { + name: "test".to_string(), + base_url: Some("https://test.com".to_string()), + env_key: Some("TEST_API_KEY".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(1000), + requires_openai_auth: false, + }; + + let otel_event_manager = otel_event_manager(); + + let events = collect_events(&[sse1.as_bytes()], provider, otel_event_manager).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(err @ CodexErr::QuotaExceeded) => { + assert_eq!(err.to_string(), CodexErr::QuotaExceeded.to_string()); + } + other => panic!("unexpected quota exceeded event: {other:?}"), + } + } + // ──────────────────────────── // Table-driven test from `main` // ──────────────────────────── diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 64cd0023..70728ba6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1928,6 +1928,7 @@ async fn run_turn( return Err(CodexErr::UsageLimitReached(e)); } Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded), + Err(e @ CodexErr::QuotaExceeded) => return Err(e), Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e), Err(e) => { // Use the configured provider-specific stream retry budget. diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6ca8970e..10e936f1 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -109,6 +109,9 @@ pub enum CodexErr { #[error("{0}")] ConnectionFailed(ConnectionFailedError), + #[error("Quota exceeded. Check your plan and billing details.")] + QuotaExceeded, + #[error( "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." )] diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index bec4f942..a8841674 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -26,6 +26,7 @@ mod model_overrides; mod model_tools; mod otel; mod prompt_caching; +mod quota_exceeded; mod read_file; mod resume; mod review; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs new file mode 100644 index 00000000..4cb634c1 --- /dev/null +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -0,0 +1,75 @@ +use std::time::Duration; + +use anyhow::Result; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_with_timeout; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn quota_exceeded_emits_single_error_event() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex(); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + json!({ + "type": "response.failed", + "response": { + "id": "resp-1", + "error": { + "code": "insufficient_quota", + "message": "You exceeded your current quota, please check your plan and billing details." + } + } + }), + ]), + ) + .await; + + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "quota?".into(), + }], + }) + .await + .unwrap(); + + let mut error_events = 0; + + loop { + let event = + wait_for_event_with_timeout(&test.codex, |_| true, Duration::from_secs(5)).await; + + match event { + EventMsg::Error(err) => { + error_events += 1; + assert_eq!( + err.message, + "Quota exceeded. Check your plan and billing details." + ); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + assert_eq!(error_events, 1, "expected exactly one Codex:Error event"); + + Ok(()) +}