From 5a7353f0401b970c44cf7d428ef23d74f3c366d5 Mon Sep 17 00:00:00 2001 From: Hung Date: Fri, 30 Oct 2026 20:48:19 +0000 Subject: [PATCH] Treat audio validation failures as non-retryable input errors (#58434) ## What changed Map audio size, data, format, and sample-rate validation failures to `InvalidAudioInput` for HTTP 400 responses and streamed errors. Preserve the provider's message, falling back to `Invalid audio input.` when it is absent or blank. Classify these errors as non-retryable and expose them as `BadRequest`. Redact their messages in doctor diagnostics and group them as invalid requests in telemetry. ## Testing Add coverage for all six audio validation codes across HTTP errors, streamed `error` events, and `response.failed` events, checking error classification, message preservation, and the absence of retries. Extend doctor diagnostic redaction coverage for `InvalidAudioInput`. GitOrigin-RevId: 0a59964bcabd83997bdb32fc9318a5482dac33b0 --- codex-rs/cli/src/doctor.rs | 5 +++ codex-rs/codex-api/src/api_bridge.rs | 4 ++ codex-rs/codex-api/src/error.rs | 18 ++++++++ .../src/sse/responses_error_tests.rs | 41 +++++++++++++++++++ .../guardian-v2/src/async_scorer/metrics.rs | 4 +- .../src/async_scorer/sampler/execution.rs | 1 + codex-rs/protocol/src/error.rs | 6 ++- codex-rs/response-debug-context/src/lib.rs | 6 +-- 8 files changed, 80 insertions(+), 5 deletions(-) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 5559daf2ec..fd1f1c8fa1 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2481,6 +2481,9 @@ fn websocket_error_detail(err: &ApiError) -> String { ApiError::InvalidRequest { .. } => { "handshake error: invalid request: [redacted]".to_string() } + ApiError::InvalidAudioInput { .. } => { + "handshake error: invalid audio input: [redacted]".to_string() + } ApiError::ModelNotFound { .. } => { "handshake error: model not found: [redacted]".to_string() } @@ -3317,6 +3320,7 @@ mod tests { }, ApiError::RateLimit(message()), ApiError::InvalidRequest { message: message() }, + ApiError::InvalidAudioInput { message: message() }, ApiError::ModelNotFound { message: message(), status: Some(http::StatusCode::NOT_FOUND), @@ -3344,6 +3348,7 @@ mod tests { "handshake error: rate limit exceeded: [redacted]", "handshake error: rate limit: [redacted]", "handshake error: invalid request: [redacted]", + "handshake error: invalid audio input: [redacted]", "handshake error: model not found: [redacted]", "handshake error: invalid prompt: [redacted]", "handshake error: cyber policy: [redacted]", diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 0e849c1388..095f4ee6bb 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -37,6 +37,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { | ApiError::FlexUnavailable | ApiError::RateLimit(_) | ApiError::InvalidRequest { .. } + | ApiError::InvalidAudioInput { .. } | ApiError::ModelNotFound { .. } | ApiError::InvalidPrompt { .. } | ApiError::CyberPolicy { .. } @@ -83,6 +84,9 @@ fn map_api_error_details(err: ApiError) -> CodexErr { }) } ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), + ApiError::InvalidAudioInput { message } => { + CodexErr::new(CodexErrorDetails::InvalidAudioInput { message }) + } ApiError::ModelNotFound { message, status } => { CodexErr::new(CodexErrorDetails::ModelNotFound { message, status }) } diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 6732a806cc..93e42d1953 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -40,6 +40,8 @@ pub enum ApiError { #[error("invalid request: {message}")] InvalidRequest { message: String }, #[error("{message}")] + InvalidAudioInput { message: String }, + #[error("{message}")] ModelNotFound { message: String, status: Option, @@ -77,6 +79,22 @@ pub(crate) fn parse_terminal_provider_error( let error = error?; let code = error.get("code")?.as_str()?; match (code, status) { + ( + "audio_too_large" + | "audio_too_short" + | "invalid_audio" + | "invalid_audio_data" + | "invalid_audio_format" + | "missing_audio_sample_rate", + None | Some(StatusCode::BAD_REQUEST), + ) => Some(ApiError::InvalidAudioInput { + message: error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .unwrap_or("Invalid audio input.") + .to_owned(), + }), ("flex_unavailable", None | Some(StatusCode::TOO_MANY_REQUESTS)) => { Some(ApiError::FlexUnavailable) } diff --git a/codex-rs/codex-api/src/sse/responses_error_tests.rs b/codex-rs/codex-api/src/sse/responses_error_tests.rs index 43f6aed886..298c9850cf 100644 --- a/codex-rs/codex-api/src/sse/responses_error_tests.rs +++ b/codex-rs/codex-api/src/sse/responses_error_tests.rs @@ -16,6 +16,47 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::time::Duration; +#[test] +fn audio_validation_codes_map_to_non_retryable_audio_input_errors_on_all_transports() { + for code in [ + "audio_too_large", + "audio_too_short", + "invalid_audio", + "invalid_audio_data", + "invalid_audio_format", + "missing_audio_sample_rate", + ] { + let detail = json!({"code": code, "message": "Invalid recording."}); + let http_error = map_api_error(crate::error::ApiError::Transport( + crate::TransportError::Http { + status: StatusCode::BAD_REQUEST, + url: None, + headers: None, + body: Some(json!({"error": detail}).to_string()), + retry_after: None, + }, + )); + let mut errors = vec![http_error]; + for failure in [ + json!({"type": "error", "error": detail}), + json!({"type": "response.failed", "response": {"error": detail}}), + ] { + let event = serde_json::from_value(failure).unwrap(); + errors.push(map_api_error( + process_responses_event(event).unwrap_err().into_api_error(), + )); + } + for error in errors { + assert_eq!(error.retry_delay(/*retry_count*/ 1), None); + assert!(matches!( + error.details(), + codex_protocol::error::CodexErrorDetails::InvalidAudioInput { .. } + )); + assert_eq!(error.to_string(), "Invalid recording."); + } + } +} + /// Only the explicit policy capacity code changes overload attribution. #[tokio::test(start_paused = true)] async fn policy_capacity_classification_preserves_overload_retry_behavior() { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs index 30182f00b7..ab8bb302db 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/metrics.rs @@ -53,7 +53,9 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str { ApiError::Retryable { .. } => "retryable_api_error", ApiError::RateLimitExceeded { .. } | ApiError::RateLimit(_) => "rate_limit", ApiError::ModelNotFound { .. } => "model_not_found", - ApiError::InvalidRequest { .. } | ApiError::InvalidPrompt { .. } => "invalid_request", + ApiError::InvalidRequest { .. } + | ApiError::InvalidAudioInput { .. } + | ApiError::InvalidPrompt { .. } => "invalid_request", ApiError::CyberPolicy { .. } | ApiError::BioPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } => "policy_error", diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs index 1d8b41c6de..43bc8898c5 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler/execution.rs @@ -158,6 +158,7 @@ impl SamplingExecution { | ApiError::UsageNotIncluded | ApiError::RateLimit(_) | ApiError::InvalidRequest { .. } + | ApiError::InvalidAudioInput { .. } | ApiError::ModelNotFound { .. } | ApiError::InvalidPrompt { .. } | ApiError::MisalignmentPolicyViolation { .. } diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 108d36429e..a8a8827b23 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -152,6 +152,8 @@ pub enum CodexErrorDetails { #[error("{0}")] InvalidRequest(String), #[error("{message}")] + InvalidAudioInput { message: String }, + #[error("{message}")] ModelNotFound { message: String, status: Option, @@ -429,6 +431,7 @@ impl CodexErr { | CodexErrorDetails::QuotaExceeded | CodexErrorDetails::InvalidImageRequest() | CodexErrorDetails::InvalidRequest(_) + | CodexErrorDetails::InvalidAudioInput { .. } | CodexErrorDetails::ResponseIncomplete(_) | CodexErrorDetails::ModelNotFound { .. } | CodexErrorDetails::InvalidPrompt { .. } @@ -534,7 +537,8 @@ impl CodexErr { CodexErrorDetails::SessionConfiguredNotFirstEvent | CodexErrorDetails::InternalServerError | CodexErrorDetails::InternalAgentDied => CodexErrorInfo::InternalServerError, - CodexErrorDetails::UnsupportedOperation(_) + CodexErrorDetails::InvalidAudioInput { .. } + | CodexErrorDetails::UnsupportedOperation(_) | CodexErrorDetails::ThreadNotFound(_) | CodexErrorDetails::AgentLimitReached { .. } => CodexErrorInfo::BadRequest, CodexErrorDetails::Sandbox(_) => CodexErrorInfo::SandboxError, diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index 768ad5893c..3667f793b4 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -86,9 +86,9 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { ApiError::RateLimitExceeded { .. } => "rate limit exceeded".to_string(), ApiError::RateLimit(_) => "rate limit".to_string(), ApiError::ModelNotFound { .. } => "model not found".to_string(), - ApiError::InvalidRequest { .. } | ApiError::InvalidPrompt { .. } => { - "invalid request".to_string() - } + ApiError::InvalidRequest { .. } + | ApiError::InvalidAudioInput { .. } + | ApiError::InvalidPrompt { .. } => "invalid request".to_string(), ApiError::CyberPolicy { .. } => "cyber policy".to_string(), ApiError::BioPolicy { .. } => "bio policy".to_string(), ApiError::MisalignmentPolicyViolation { .. } => "misalignment policy violation".to_string(),