Preserve bio policy errors as a distinct non-retryable error (#46306)

## Why

Streaming `bio_policy` failures were classified as generic invalid requests, losing their policy-specific classification.

## What changed

- Add `BioPolicy` errors across the API and core protocol, recognizing streaming failures and HTTP 400 responses, including wrapped WebSocket errors.
- Preserve server messages and use a biological-risk fallback when the message is missing or blank.
- Treat bio policy errors as non-retryable in core and guardian handling, and classify them in diagnostics and telemetry.
- Map `BioPolicy` to `other` in the app-server v2 protocol.

## Testing

Add coverage for error classification, message preservation and fallbacks, HTTP and wrapped WebSocket responses, guardian retry decisions, and app-server conversion. Extend the core integration test to verify that bio policy failures emit a typed error and complete the turn after a single request.

GitOrigin-RevId: 78c2647e8fc8f80297cb8a23fff06ab141099632
This commit is contained in:
Rennie
2026-09-17 21:25:55 +00:00
committed by copyberry
parent 55db7e8c88
commit fa8cf44985
16 changed files with 146 additions and 24 deletions
@@ -129,6 +129,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
CoreCodexErrorInfo::RateLimitExceeded => CodexErrorInfo::RateLimitExceeded,
CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy,
CoreCodexErrorInfo::BioPolicy => CodexErrorInfo::Other,
CoreCodexErrorInfo::MisalignmentPolicyViolation => {
CodexErrorInfo::MisalignmentPolicyViolation
}
@@ -4671,6 +4671,7 @@ fn codex_error_info_serializes_http_status_code_in_camel_case() {
fn core_error_info_converts_to_camel_case() {
for (core, expected) in [
(CoreCodexErrorInfo::CyberPolicy, json!("cyberPolicy")),
(CoreCodexErrorInfo::BioPolicy, json!("other")),
(
CoreCodexErrorInfo::RateLimitExceeded,
json!("rateLimitExceeded"),
+1
View File
@@ -2375,6 +2375,7 @@ fn websocket_error_detail(err: &ApiError) -> String {
| ApiError::RateLimit(_)
| ApiError::InvalidRequest { .. }
| ApiError::CyberPolicy { .. }
| ApiError::BioPolicy { .. }
| ApiError::MisalignmentPolicyViolation { .. }
| ApiError::ServerOverloaded => format!("handshake error: {err}"),
}
+16 -4
View File
@@ -56,6 +56,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
ApiError::CyberPolicy { message } => {
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
}
ApiError::BioPolicy { message } => CodexErr::new(CodexErrorDetails::BioPolicy { message }),
ApiError::MisalignmentPolicyViolation {
message,
misalignment,
@@ -117,16 +118,25 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
if status == http::StatusCode::BAD_REQUEST {
if let Ok(parsed) = serde_json::from_str::<Value>(&body_text)
&& let Some(error) = parsed.get("error")
&& error.get("code").and_then(Value::as_str)
== Some(CYBER_POLICY_ERROR_CODE)
&& let Some(code @ (CYBER_POLICY_ERROR_CODE | BIO_POLICY_ERROR_CODE)) =
error.get("code").and_then(Value::as_str)
{
let fallback_message = if code == BIO_POLICY_ERROR_CODE {
BIO_POLICY_FALLBACK_MESSAGE
} else {
CYBER_POLICY_FALLBACK_MESSAGE
};
let message = error
.get("message")
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string());
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
.unwrap_or_else(|| fallback_message.to_string());
if code == BIO_POLICY_ERROR_CODE {
CodexErr::new(CodexErrorDetails::BioPolicy { message })
} else {
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
}
} else if body_text
.contains("The image data you provided does not represent a valid image")
{
@@ -227,6 +237,8 @@ const X_ERROR_JSON_HEADER: &str = "x-error-json";
const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy";
const CYBER_POLICY_FALLBACK_MESSAGE: &str =
"This request has been flagged for possible cybersecurity risk.";
const BIO_POLICY_ERROR_CODE: &str = "bio_policy";
const BIO_POLICY_FALLBACK_MESSAGE: &str = "This content was flagged for possible biological risk.";
const MISALIGNMENT_POLICY_VIOLATION_ERROR_CODE: &str = "misalignment_policy_violation";
const MISALIGNMENT_POLICY_VIOLATION_FALLBACK_MESSAGE: &str =
"This request was blocked due to a misalignment policy violation.";
@@ -181,6 +181,53 @@ fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
);
}
#[test]
fn map_api_error_preserves_bio_policy() {
let err = map_api_error(ApiError::BioPolicy {
message: "This request was blocked by bio policy.".to_string(),
});
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BioPolicy);
assert_eq!(err.to_string(), "This request was blocked by bio policy.");
assert!(!err.is_retryable());
}
#[test]
fn map_api_error_maps_http_and_wrapped_websocket_bio_policy() {
for wrapped in [false, true] {
for message in [
Some("This request was blocked by bio policy."),
None,
Some(""),
Some(" "),
] {
let mut body = serde_json::json!({"error": {"code": "bio_policy"}});
if let Some(message) = message {
body["error"]["message"] = serde_json::json!(message);
}
if wrapped {
body["type"] = serde_json::json!("error");
body["status"] = serde_json::json!(400);
}
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: None,
headers: None,
body: Some(body.to_string()),
}));
let expected = message
.filter(|message| !message.trim().is_empty())
.unwrap_or("This content was flagged for possible biological risk.");
let CodexErrorDetails::BioPolicy { message } = err.details() else {
panic!("expected CodexErrorDetails::BioPolicy, got {err:?}");
};
assert_eq!(message, expected);
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BioPolicy);
assert!(!err.is_retryable());
}
}
}
#[test]
fn map_api_error_maps_misalignment_policy_violation_from_400_body() {
assert_misalignment_policy_violation_from_http_body(http::StatusCode::BAD_REQUEST);
+2
View File
@@ -35,6 +35,8 @@ pub enum ApiError {
InvalidRequest { message: String },
#[error("cyber policy: {message}")]
CyberPolicy { message: String },
#[error("bio policy: {message}")]
BioPolicy { message: String },
#[error("misalignment policy violation: {message}")]
MisalignmentPolicyViolation {
message: String,
+35 -5
View File
@@ -429,6 +429,14 @@ pub fn process_responses_event(
} else if is_cyber_policy_error(&error) {
let message = cyber_policy_message(error.message);
response_error = ApiError::CyberPolicy { message };
} else if error.code.as_deref() == Some("bio_policy") {
let message = error
.message
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| {
"This content was flagged for possible biological risk.".to_string()
});
response_error = ApiError::BioPolicy { message };
} else if error.code.as_deref() == Some("misalignment_policy_violation") {
let message = error
.message
@@ -443,8 +451,7 @@ pub fn process_responses_event(
serde_json::from_value::<MisalignmentErrorDetails>(details).ok()
}),
};
} else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy"))
{
} else if error.code.as_deref() == Some("invalid_prompt") {
let message = error
.message
.unwrap_or_else(|| "Invalid request.".to_string());
@@ -1366,7 +1373,7 @@ mod tests {
}
#[tokio::test]
async fn content_policy_errors_without_type_are_invalid_requests() {
async fn content_policy_errors_without_type_preserve_their_classification() {
for (code, expected_message) in [
(
"invalid_prompt",
@@ -1396,8 +1403,9 @@ mod tests {
let events = collect_events(&[sse1.as_bytes()]).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::InvalidRequest { message }) => {
match (code, &events[0]) {
("invalid_prompt", Err(ApiError::InvalidRequest { message }))
| ("bio_policy", Err(ApiError::BioPolicy { message })) => {
assert_eq!(message, expected_message);
}
other => panic!("unexpected event for {code}: {other:?}"),
@@ -1405,6 +1413,28 @@ mod tests {
}
}
#[tokio::test]
async fn bio_policy_error_uses_fallback_for_missing_or_blank_message() {
for message in [None, Some(""), Some(" ")] {
let mut event = json!({
"type": "response.failed",
"response": { "error": { "code": "bio_policy" } },
});
if let Some(message) = message {
event["response"]["error"]["message"] = json!(message);
}
let sse = format!("event: response.failed\ndata: {event}\n\n");
let events = collect_events(&[sse.as_bytes()]).await;
match events.as_slice() {
[Err(ApiError::BioPolicy { message })] => assert_eq!(
message,
"This content was flagged for possible biological risk."
),
other => panic!("unexpected events: {other:?}"),
}
}
}
#[tokio::test]
async fn table_driven_event_kinds() {
struct TestCase {
@@ -38,6 +38,7 @@ const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber";
const CYBER_POLICY_MESSAGE: &str =
"This request has been flagged for potentially high-risk cyber activity.";
const BIO_POLICY_MESSAGE: &str = "This request has been flagged for possible biological risk.";
fn disabled_text_turn(test: &TestCodex, text: &str) -> TurnInputRequest {
let (sandbox_policy, permission_profile) =
@@ -105,34 +106,48 @@ async fn openai_model_header_mismatch_emits_warning_event() -> Result<()> {
Ok(())
}
#[test_case::test_case("cyber_policy", CYBER_POLICY_MESSAGE, CodexErrorInfo::CyberPolicy; "cyber")]
#[test_case::test_case("bio_policy", BIO_POLICY_MESSAGE, CodexErrorInfo::BioPolicy; "bio")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cyber_policy_response_emits_typed_error_without_retry() -> Result<()> {
async fn policy_response_emits_typed_error_without_retry(
code: &str,
message: &str,
error_info: CodexErrorInfo,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"message": CYBER_POLICY_MESSAGE,
"message": message,
"type": "invalid_request",
"param": null,
"code": "cyber_policy"
"code": code
}
}));
let mock = mount_response_once(&server, response).await;
let mut builder = test_codex().with_model(REQUESTED_MODEL);
let test = builder.build(&server).await?;
let test = builder.build_with_auto_env(&server).await?;
test.codex
.start_or_steer_turn(disabled_text_turn(&test, "trigger cyber policy error"))
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "trigger policy error".to_string(),
text_elements: Vec::new(),
}]))
.await?;
let error = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await;
let EventMsg::Error(error) = error else {
panic!("expected error event");
};
assert_eq!(error.message, CYBER_POLICY_MESSAGE);
assert_eq!(error.codex_error_info, Some(CodexErrorInfo::CyberPolicy));
assert_eq!(error.message, message);
assert_eq!(error.codex_error_info, Some(error_info));
let _ = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
mock.single_request();
@@ -100,6 +100,7 @@ fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
| CodexErrorInfo::SessionBudgetExceeded
| CodexErrorInfo::UsageLimitExceeded
| CodexErrorInfo::CyberPolicy
| CodexErrorInfo::BioPolicy
| CodexErrorInfo::MisalignmentPolicyViolation
| CodexErrorInfo::Unauthorized
| CodexErrorInfo::BadRequest
@@ -92,6 +92,13 @@ fn guardian_review_retry_only_retries_transient_session_and_parse_errors() {
)),
false,
),
(
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
anyhow::anyhow!("bio policy"),
CodexErrorInfo::BioPolicy,
)),
false,
),
(
GuardianReviewOutcome::Error(GuardianReviewError::parse(anyhow::anyhow!("parse"))),
true,
@@ -49,9 +49,9 @@ pub(super) fn sampler_failure_reason(error: &LunaSamplerError) -> &'static str {
ApiError::Retryable { .. } => "retryable_api_error",
ApiError::RateLimitExceeded { .. } | ApiError::RateLimit(_) => "rate_limit",
ApiError::InvalidRequest { .. } => "invalid_request",
ApiError::CyberPolicy { .. } | ApiError::MisalignmentPolicyViolation { .. } => {
"policy_error"
}
ApiError::CyberPolicy { .. }
| ApiError::BioPolicy { .. }
| ApiError::MisalignmentPolicyViolation { .. } => "policy_error",
ApiError::ServerOverloaded => "server_overloaded",
},
}
@@ -91,7 +91,8 @@ impl SamplingExecution {
| ApiError::RateLimit(_)
| ApiError::InvalidRequest { .. }
| ApiError::MisalignmentPolicyViolation { .. }
| ApiError::CyberPolicy { .. },
| ApiError::CyberPolicy { .. }
| ApiError::BioPolicy { .. },
) => false,
};
if retryable && *retries < MAX_SAMPLING_RETRIES {
+4
View File
@@ -138,6 +138,8 @@ pub enum CodexErrorDetails {
#[error("{message}")]
CyberPolicy { message: String },
#[error("{message}")]
BioPolicy { message: String },
#[error("{message}")]
MisalignmentPolicyViolation {
message: String,
misalignment: Option<MisalignmentErrorDetails>,
@@ -394,6 +396,7 @@ impl CodexErr {
| CodexErrorDetails::UsageLimitReached(_)
| CodexErrorDetails::ServerOverloaded
| CodexErrorDetails::CyberPolicy { .. }
| CodexErrorDetails::BioPolicy { .. }
| CodexErrorDetails::MisalignmentPolicyViolation { .. } => false,
CodexErrorDetails::Stream(..)
| CodexErrorDetails::RateLimitExceeded(_)
@@ -439,6 +442,7 @@ impl CodexErr {
| CodexErrorDetails::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
CodexErrorDetails::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy,
CodexErrorDetails::BioPolicy { .. } => CodexErrorInfo::BioPolicy,
CodexErrorDetails::MisalignmentPolicyViolation { .. } => {
CodexErrorInfo::MisalignmentPolicyViolation
}
+2
View File
@@ -1858,6 +1858,7 @@ pub enum CodexErrorInfo {
RateLimitExceeded,
ServerOverloaded,
CyberPolicy,
BioPolicy,
MisalignmentPolicyViolation,
HttpConnectionFailed {
http_status_code: Option<u16>,
@@ -1899,6 +1900,7 @@ impl CodexErrorInfo {
| Self::RateLimitExceeded
| Self::ServerOverloaded
| Self::CyberPolicy
| Self::BioPolicy
| Self::MisalignmentPolicyViolation
| Self::HttpConnectionFailed { .. }
| Self::ResponseStreamConnectionFailed { .. }
+2 -4
View File
@@ -19,10 +19,7 @@ pub struct ResponseDebugContext {
pub fn extract_response_debug_context(transport: &TransportError) -> ResponseDebugContext {
let mut context = ResponseDebugContext::default();
let TransportError::Http {
headers, body: _, ..
} = transport
else {
let TransportError::Http { headers, .. } = transport else {
return context;
};
@@ -85,6 +82,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String {
ApiError::RateLimit(_) => "rate limit".to_string(),
ApiError::InvalidRequest { .. } => "invalid request".to_string(),
ApiError::CyberPolicy { .. } => "cyber policy".to_string(),
ApiError::BioPolicy { .. } => "bio policy".to_string(),
ApiError::MisalignmentPolicyViolation { .. } => "misalignment policy violation".to_string(),
ApiError::ServerOverloaded => "server overloaded".to_string(),
}