From efd82025a5878297c552d9493f03171fbb3e0df3 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sat, 23 Aug 2025 13:04:06 +0900 Subject: [PATCH] fix: #2606 better user experience with unrecoverable errors --- codex-rs/core/src/client.rs | 3 + codex-rs/core/src/codex.rs | 9 +- codex-rs/core/src/error.rs | 3 + .../core/tests/unexpected_status_no_retry.rs | 88 +++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 codex-rs/core/tests/unexpected_status_no_retry.rs diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6206b4264a..e38f51b038 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -311,6 +311,9 @@ impl ModelClient { if status == StatusCode::INTERNAL_SERVER_ERROR { return Err(CodexErr::InternalServerError); } + if status == StatusCode::UNAUTHORIZED { + return Err(CodexErr::UnauthorizedError); + } return Err(CodexErr::RetryLimit(status)); } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ec053ce4aa..a6321029a4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1566,7 +1566,14 @@ 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)) => { + Err( + e @ (CodexErr::UsageLimitReached(_) + | CodexErr::UsageNotIncluded + | CodexErr::UnexpectedStatus(_, _) + | CodexErr::RetryLimit(_) + | CodexErr::UnauthorizedError + | CodexErr::InternalServerError), + ) => { return Err(e); } Err(e) => { diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 356c23011c..dc4b004033 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -83,6 +83,9 @@ pub enum CodexErr { #[error("We're currently experiencing high demand, which may cause temporary errors.")] InternalServerError, + #[error("The API key is invalid or has expired. Please check your API key and try again.")] + UnauthorizedError, + /// Retry limit exceeded. #[error("exceeded retry limit, last status: {0}")] RetryLimit(StatusCode), diff --git a/codex-rs/core/tests/unexpected_status_no_retry.rs b/codex-rs/core/tests/unexpected_status_no_retry.rs new file mode 100644 index 0000000000..c7f5536971 --- /dev/null +++ b/codex-rs/core/tests/unexpected_status_no_retry.rs @@ -0,0 +1,88 @@ +use std::time::Duration; + +use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::WireApi; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_login::CodexAuth; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event_with_timeout; +use serde_json::json; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fails_fast_on_unexpected_status() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let server = MockServer::start().await; + + let err_body = json!({ + "error": {"message": "bad request"} + }); + let tmpl = ResponseTemplate::new(400) + .insert_header("content-type", "application/json") + .set_body_string(err_body.to_string()); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(tmpl) + .expect(1) + .mount(&server) + .await; + + let provider = ModelProviderInfo { + name: "openai".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: Some("PATH".into()), + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(3), + stream_idle_timeout_ms: Some(2000), + requires_openai_auth: false, + }; + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = provider; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .unwrap() + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event_with_timeout( + &codex, + |ev| matches!(ev, EventMsg::Error(_)), + Duration::from_secs(5), + ) + .await; +}