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
This commit is contained in:
Hung
2026-10-30 21:01:23 +00:00
committed by copyberry
parent 2546265e3e
commit 5a7353f040
8 changed files with 80 additions and 5 deletions
+5
View File
@@ -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]",
+4
View File
@@ -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 })
}
+18
View File
@@ -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<StatusCode>,
@@ -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)
}
@@ -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() {
@@ -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",
@@ -158,6 +158,7 @@ impl SamplingExecution {
| ApiError::UsageNotIncluded
| ApiError::RateLimit(_)
| ApiError::InvalidRequest { .. }
| ApiError::InvalidAudioInput { .. }
| ApiError::ModelNotFound { .. }
| ApiError::InvalidPrompt { .. }
| ApiError::MisalignmentPolicyViolation { .. }
+5 -1
View File
@@ -152,6 +152,8 @@ pub enum CodexErrorDetails {
#[error("{0}")]
InvalidRequest(String),
#[error("{message}")]
InvalidAudioInput { message: String },
#[error("{message}")]
ModelNotFound {
message: String,
status: Option<StatusCode>,
@@ -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,
+3 -3
View File
@@ -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(),