Files
codex/codex-rs/codex-api/src/sse/responses_error_tests.rs
T
Hung 5a7353f040 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
2026-10-30 21:01:23 +00:00

269 lines
9.5 KiB
Rust

//! Regression coverage for retry advice carried by failed Responses events.
use super::process_responses_event;
use super::spawn_response_stream;
use crate::api_bridge::map_api_error;
use crate::common::ResponseEvent;
use bytes::Bytes;
use codex_client::StreamResponse;
use codex_http_client::RetryAfter;
use codex_protocol::protocol::CodexErrorInfo;
use futures::StreamExt;
use futures::stream;
use http::HeaderMap;
use http::StatusCode;
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() {
for (code, error_type, expected) in [
(
"policy_capacity_unavailable",
None,
CodexErrorInfo::PolicyCapacityUnavailable,
),
(
"server_is_overloaded",
Some("policy_error"),
CodexErrorInfo::ServerOverloaded,
),
(
"server_is_overloaded",
None,
CodexErrorInfo::ServerOverloaded,
),
] {
for retry_after in [Some("3"), None] {
let event = serde_json::from_value(json!({
"type": "response.failed",
"response": {"error": {
"type": error_type,
"code": code,
"headers": {"Retry-After": retry_after},
}},
}))
.unwrap();
let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error());
assert_eq!(
(
error.to_codex_protocol_error(),
error.retry_delay(/*retry_count*/ 1),
error.to_string()
),
(
expected.clone(),
retry_after.map(|_| Duration::from_secs(3)),
if expected == CodexErrorInfo::PolicyCapacityUnavailable {
"Policy-restricted capacity is unavailable. Please try again later."
} else {
"Selected model is at capacity. Please try a different model."
}
.to_string()
)
);
}
}
}
/// Flex failures remain terminal even with retry headers and a later completed event.
#[tokio::test(start_paused = true)]
async fn flex_failure_with_retry_header_ends_stream_immediately() {
let error = json!({
"code": "flex_unavailable",
"plan_type": 42,
"headers": {"retry-after": "300"}
});
for failure in [
json!({"type": "error", "error": error}),
json!({"type": "response.failed", "response": {"error": error}}),
] {
let completed = json!({"type": "response.completed", "response": {"id": "later"}});
let bytes = stream::iter([Ok(Bytes::from(format!(
"data: {failure}\n\ndata: {completed}\n\n"
)))])
.chain(stream::pending());
let mut stream = spawn_response_stream(
StreamResponse {
status: StatusCode::OK,
headers: HeaderMap::new(),
bytes: Box::pin(bytes),
},
Duration::from_secs(60),
/*telemetry*/ None,
/*turn_state*/ None,
);
let error = loop {
match stream.next().await {
Some(Ok(ResponseEvent::RateLimits(_))) => {}
Some(Err(error)) => break map_api_error(error),
event => panic!("expected a Flex error, got {event:?}"),
}
};
assert_eq!(
(
error.to_codex_protocol_error(),
error.retry_delay(/*retry_count*/ 1)
),
(CodexErrorInfo::FlexUnavailable, None)
);
assert!(stream.next().await.is_none());
}
}
#[test]
fn statusless_model_errors_have_no_http_status() {
let error = json!({"code": "model_not_found"});
for event in [
json!({"type": "error", "error": error}),
json!({"type": "response.failed", "response": {"error": error}}),
] {
let event = serde_json::from_value(event).unwrap();
let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error());
assert_eq!(
(
error.to_codex_protocol_error(),
error.http_status_code_value()
),
(CodexErrorInfo::ModelNotFound, None)
);
}
}
/// Error headers beat rate-limit message advice; invalid ones fall back.
#[tokio::test(start_paused = true)]
async fn rate_limit_plaintext_loses_to_valid_error_header() {
for code in ["rate_limit_exceeded", "slow_down"] {
for (error_header, expected_seconds) in [
(Some("2"), 2),
(Some("0"), 0),
(Some("Wed, 21 Oct 2015 07:28:00 GMT"), 0),
(Some("bad"), 35),
(None, 35),
] {
let event = serde_json::from_value(json!({
"type": "response.failed",
"response": {
"error": {
"code": code,
"message": "Try again in 35 seconds.",
"headers": {"Retry-After": error_header}
}
}
}))
.unwrap();
let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error());
assert_eq!(
error.retry_after(),
RetryAfter::from_delay(Duration::from_secs(expected_seconds))
);
}
}
}
/// Retry advice uses the same HTTP value validation as other event headers.
#[tokio::test(start_paused = true)]
async fn streamed_retry_after_uses_http_header_validation() {
for (error_headers, expected_seconds) in [
(json!({"retry-after": "\n5\n"}), 12),
(json!({"retry-after": "\t5\t"}), 5),
(json!({"Retry-After": "5", "retry-after": "30"}), 30),
] {
let event = serde_json::from_value(json!({
"type": "response.failed",
"response": {"error": {"code": "rate_limit_exceeded", "message": "Try again in 12 seconds.", "headers": error_headers}}
}))
.unwrap();
let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error());
assert_eq!(
error.retry_after(),
RetryAfter::from_delay(Duration::from_secs(expected_seconds))
);
}
}
/// Event headers retain their deadlines for overload and generic failures.
#[tokio::test(start_paused = true)]
async fn retry_header_survives_other_failed_responses() {
for (code, expected_code) in [
("server_is_overloaded", CodexErrorInfo::ServerOverloaded),
("unknown_failure", CodexErrorInfo::Other),
] {
let event = serde_json::from_value(json!({
"type": "response.failed",
"response": {"error": {"code": code, "headers": {"retry-after": "7"}}},
}))
.unwrap();
let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error());
assert_eq!(
(
error.to_codex_protocol_error(),
error.retry_after(),
error.retry_delay(/*retry_count*/ 1),
),
(
expected_code,
RetryAfter::from_delay(Duration::from_secs(7)),
Some(Duration::from_secs(7)),
)
);
}
}
/// An event header cannot make a quota or policy failure retryable.
#[tokio::test(start_paused = true)]
async fn retry_header_does_not_override_terminal_errors() {
for code in ["insufficient_quota", "cyber_policy", "bio_policy"] {
let event = serde_json::from_value(json!({
"type": "response.failed",
"response": {"error": {"code": code, "headers": {"retry-after": "1"}}}
}))
.unwrap();
let error = map_api_error(process_responses_event(event).unwrap_err().into_api_error());
assert_eq!(error.retry_delay(/*retry_count*/ 1), None, "{code}");
}
}