mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## Why `invalid_prompt` responses were classified as generic invalid requests, losing the server's specific error classification. ## What changed Add `InvalidPrompt` variants to the API and core error types and serialize the core protocol classification as `invalid_prompt`. Recognize the code in HTTP 400 responses, wrapped WebSocket errors, and SSE `response.failed` events while preserving server messages and keeping the error non-retryable. Map it to `other` in the app-server v2 protocol. ## Testing Extend coverage for typed error mapping, missing and blank messages, protocol conversion, and guardian retry handling. Exercise HTTP and SSE failures in core integration tests, asserting the error message, serialized classification, and a single request without retry. GitOrigin-RevId: 5d9a299a013b06158a4dc27a4054f3dbc5fd0f4d
178 lines
6.8 KiB
Rust
178 lines
6.8 KiB
Rust
use base64::Engine;
|
|
use codex_api::ApiError;
|
|
use codex_api::TransportError;
|
|
|
|
const REQUEST_ID_HEADER: &str = "x-request-id";
|
|
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
|
|
const CF_RAY_HEADER: &str = "cf-ray";
|
|
const AUTH_ERROR_HEADER: &str = "x-openai-authorization-error";
|
|
const X_ERROR_JSON_HEADER: &str = "x-error-json";
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
pub struct ResponseDebugContext {
|
|
pub request_id: Option<String>,
|
|
pub cf_ray: Option<String>,
|
|
pub auth_error: Option<String>,
|
|
pub auth_error_code: Option<String>,
|
|
}
|
|
|
|
pub fn extract_response_debug_context(transport: &TransportError) -> ResponseDebugContext {
|
|
let mut context = ResponseDebugContext::default();
|
|
|
|
let TransportError::Http { headers, .. } = transport else {
|
|
return context;
|
|
};
|
|
|
|
let extract_header = |name: &str| {
|
|
headers
|
|
.as_ref()
|
|
.and_then(|headers| headers.get(name))
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_string)
|
|
};
|
|
|
|
context.request_id =
|
|
extract_header(REQUEST_ID_HEADER).or_else(|| extract_header(OAI_REQUEST_ID_HEADER));
|
|
context.cf_ray = extract_header(CF_RAY_HEADER);
|
|
context.auth_error = extract_header(AUTH_ERROR_HEADER);
|
|
context.auth_error_code = extract_header(X_ERROR_JSON_HEADER).and_then(|encoded| {
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.ok()?;
|
|
let parsed = serde_json::from_slice::<serde_json::Value>(&decoded).ok()?;
|
|
parsed
|
|
.get("error")
|
|
.and_then(|error| error.get("code"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::to_string)
|
|
});
|
|
|
|
context
|
|
}
|
|
|
|
pub fn extract_response_debug_context_from_api_error(error: &ApiError) -> ResponseDebugContext {
|
|
match error {
|
|
ApiError::Transport(transport) => extract_response_debug_context(transport),
|
|
_ => ResponseDebugContext::default(),
|
|
}
|
|
}
|
|
|
|
pub fn telemetry_transport_error_message(error: &TransportError) -> String {
|
|
match error {
|
|
TransportError::Http { status, .. } => format!("http {}", status.as_u16()),
|
|
TransportError::RetryLimit => "retry limit reached".to_string(),
|
|
TransportError::ResponseTooLarge { .. } => "response body too large".to_string(),
|
|
TransportError::Timeout => "timeout".to_string(),
|
|
TransportError::Connection(err) => err.to_string(),
|
|
TransportError::Network(err) => err.to_string(),
|
|
TransportError::Build(err) => err.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn telemetry_api_error_message(error: &ApiError) -> String {
|
|
match error {
|
|
ApiError::Transport(transport) => telemetry_transport_error_message(transport),
|
|
ApiError::Api { status, .. } => format!("api error {}", status.as_u16()),
|
|
ApiError::Stream(err) => err.to_string(),
|
|
ApiError::ContextWindowExceeded => "context window exceeded".to_string(),
|
|
ApiError::QuotaExceeded => "quota exceeded".to_string(),
|
|
ApiError::UsageNotIncluded => "usage not included".to_string(),
|
|
ApiError::Retryable { .. } => "retryable error".to_string(),
|
|
ApiError::RateLimitExceeded { .. } => "rate limit exceeded".to_string(),
|
|
ApiError::RateLimit(_) => "rate limit".to_string(),
|
|
ApiError::InvalidRequest { .. } | 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(),
|
|
ApiError::ServerOverloaded => "server overloaded".to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::ResponseDebugContext;
|
|
use super::extract_response_debug_context;
|
|
use super::telemetry_api_error_message;
|
|
use super::telemetry_transport_error_message;
|
|
use codex_api::ApiError;
|
|
use codex_api::TransportError;
|
|
use http::HeaderMap;
|
|
use http::HeaderValue;
|
|
use http::StatusCode;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn extract_response_debug_context_decodes_identity_headers() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-oai-request-id", HeaderValue::from_static("req-auth"));
|
|
headers.insert("cf-ray", HeaderValue::from_static("ray-auth"));
|
|
headers.insert(
|
|
"x-openai-authorization-error",
|
|
HeaderValue::from_static("missing_authorization_header"),
|
|
);
|
|
headers.insert(
|
|
"x-error-json",
|
|
HeaderValue::from_static("eyJlcnJvciI6eyJjb2RlIjoidG9rZW5fZXhwaXJlZCJ9fQ=="),
|
|
);
|
|
|
|
let context = extract_response_debug_context(&TransportError::Http {
|
|
status: StatusCode::UNAUTHORIZED,
|
|
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
|
|
headers: Some(headers),
|
|
body: Some(r#"{"error":{"message":"plain text error"},"status":401}"#.to_string()),
|
|
});
|
|
|
|
assert_eq!(
|
|
context,
|
|
ResponseDebugContext {
|
|
request_id: Some("req-auth".to_string()),
|
|
cf_ray: Some("ray-auth".to_string()),
|
|
auth_error: Some("missing_authorization_header".to_string()),
|
|
auth_error_code: Some("token_expired".to_string()),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn telemetry_error_messages_omit_upstream_bodies() {
|
|
let transport = TransportError::Http {
|
|
status: StatusCode::UNAUTHORIZED,
|
|
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
|
|
headers: None,
|
|
body: Some(r#"{"error":{"message":"secret token leaked"}}"#.to_string()),
|
|
};
|
|
|
|
assert_eq!(telemetry_transport_error_message(&transport), "http 401");
|
|
assert_eq!(
|
|
telemetry_api_error_message(&ApiError::Transport(transport)),
|
|
"http 401"
|
|
);
|
|
assert_eq!(
|
|
telemetry_api_error_message(&ApiError::RateLimitExceeded {
|
|
message: "private upstream diagnostic".to_string(),
|
|
delay: Some(std::time::Duration::from_secs(1)),
|
|
}),
|
|
"rate limit exceeded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn telemetry_error_messages_preserve_non_http_details() {
|
|
let network = TransportError::Network("dns lookup failed".to_string());
|
|
let build = TransportError::Build("invalid header value".to_string());
|
|
let stream = ApiError::Stream("socket closed".to_string());
|
|
|
|
assert_eq!(
|
|
telemetry_transport_error_message(&network),
|
|
"dns lookup failed"
|
|
);
|
|
assert_eq!(
|
|
telemetry_transport_error_message(&build),
|
|
"invalid header value"
|
|
);
|
|
assert_eq!(telemetry_api_error_message(&stream), "socket closed");
|
|
}
|
|
}
|