From 93a5e0fe1c1cb103bea37b44367023e3095e27ef Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:22:08 -0800 Subject: [PATCH] fix(codex-api): treat invalid_prompt as non-retryable (#9400) **Goal**: Prevent response.failed events with `invalid_prompt` from being treated as retryable errors so the UI shows the actual error message instead of continually retrying. **Before**: Codex would continue to retry despite the prompt being marked as disallowed **After**: Codex will stop retrying once prompt is marked disallowed --- codex-rs/codex-api/src/error.rs | 2 ++ codex-rs/codex-api/src/sse/responses.rs | 30 +++++++++++++++++++++++++ codex-rs/core/src/api_bridge.rs | 1 + 3 files changed, 33 insertions(+) diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 60118e872..e7fbd1454 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -25,6 +25,8 @@ pub enum ApiError { }, #[error("rate limit: {0}")] RateLimit(String), + #[error("invalid request: {message}")] + InvalidRequest { message: String }, } impl From for ApiError { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 5299310e0..a70111d98 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -217,6 +217,11 @@ pub fn process_responses_event( response_error = ApiError::QuotaExceeded; } else if is_usage_not_included(&error) { response_error = ApiError::UsageNotIncluded; + } else if is_invalid_prompt_error(&error) { + let message = error + .message + .unwrap_or_else(|| "Invalid request.".to_string()); + response_error = ApiError::InvalidRequest { message }; } else { let delay = try_parse_retry_after(&error); let message = error.message.unwrap_or_default(); @@ -396,6 +401,10 @@ fn is_usage_not_included(error: &Error) -> bool { error.code.as_deref() == Some("usage_not_included") } +fn is_invalid_prompt_error(error: &Error) -> bool { + error.code.as_deref() == Some("invalid_prompt") +} + fn rate_limit_regex() -> &'static regex_lite::Regex { static RE: std::sync::OnceLock = std::sync::OnceLock::new(); #[expect(clippy::unwrap_used)] @@ -711,6 +720,27 @@ mod tests { assert_matches!(events[0], Err(ApiError::QuotaExceeded)); } + #[tokio::test] + async fn invalid_prompt_without_type_is_invalid_request() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::InvalidRequest { message }) => { + assert_eq!( + message, + "Invalid prompt: we've limited access to this content for safety reasons." + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + #[tokio::test] async fn table_driven_event_kinds() { struct TestCase { diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 19bd8d5ec..79ca83981 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -28,6 +28,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { url: None, request_id: None, }), + ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), ApiError::Transport(transport) => match transport { TransportError::Http { status,