Enrich Codex proxy forwarding-error responses with context

When forward_with_retry fails for Codex endpoints (/chat/completions, /responses, /responses/compact), the handlers previously returned the bare ProxyError, surfacing a terse body to the client. Build a richer JSON error instead: the message embeds provider, model, endpoint and upstream status, and the error object carries those as structured fields alongside a stable cc_switch_* code.

Align map_proxy_error_to_status with ProxyError::into_response so the manually built responses use the canonical status codes (AuthError->401, Config/InvalidRequest->400, TransformError->422, StreamIdleTimeout->504, AlreadyRunning->409, NotRunning->503); previously these forwarder-level errors collapsed to 500.
This commit is contained in:
Jason
2026-05-30 21:42:16 +08:00
Unverified
parent 0e6f2b395f
commit f4e2c28a2b
2 changed files with 251 additions and 8 deletions
+41 -4
View File
@@ -1,6 +1,6 @@
//! 错误类型到 HTTP 状态码的映射
//!
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录和手动构建错误响应
use super::ProxyError;
@@ -12,14 +12,21 @@ use super::ProxyError;
/// - 连接失败:502 Bad Gateway
/// - 无可用 Provider503 Service Unavailable
/// - 重试耗尽:503 Service Unavailable
/// - 认证错误:401 Unauthorized
/// - 配置/请求错误:400 Bad Request
/// - 转换错误:422 Unprocessable Entity
/// - 其他错误:500 Internal Server Error
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
match error {
// 服务状态错误:与 IntoResponse 保持一致
ProxyError::AlreadyRunning => 409,
ProxyError::NotRunning => 503,
// 上游错误:使用实际状态码
ProxyError::UpstreamError { status, .. } => *status,
// 超时错误:504 Gateway Timeout
ProxyError::Timeout(_) => 504,
ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => 504,
// 转发失败/连接失败:502 Bad Gateway
ProxyError::ForwardFailed(_) => 502,
@@ -39,11 +46,17 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
// Provider 不健康:503 Service Unavailable
ProxyError::ProviderUnhealthy(_) => 503,
// 配置错误/无效请求:400 Bad Request
ProxyError::ConfigError(_) | ProxyError::InvalidRequest(_) => 400,
// 认证错误:401 Unauthorized
ProxyError::AuthError(_) => 401,
// 数据库错误:500 Internal Server Error
ProxyError::DatabaseError(_) => 500,
// 转换错误:500 Internal Server Error
ProxyError::TransformError(_) => 500,
// 转换错误:422 Unprocessable Entity
ProxyError::TransformError(_) => 422,
// 其他未知错误:500 Internal Server Error
_ => 500,
@@ -104,6 +117,30 @@ mod tests {
assert_eq!(map_proxy_error_to_status(&error), 503);
}
#[test]
fn test_map_status_matches_proxy_error_response_semantics() {
assert_eq!(
map_proxy_error_to_status(&ProxyError::AuthError("bad token".to_string())),
401
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::ConfigError("bad config".to_string())),
400
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::InvalidRequest("bad request".to_string())),
400
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::TransformError("bad transform".to_string())),
422
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::StreamIdleTimeout(30)),
504
);
}
#[test]
fn test_get_error_message() {
let error = ProxyError::UpstreamError {
+210 -4
View File
@@ -522,7 +522,7 @@ pub async fn handle_chat_completions(
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
return build_codex_proxy_error_response(&ctx, &endpoint, &err.error);
}
};
@@ -586,7 +586,7 @@ pub async fn handle_responses(
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
return build_codex_proxy_error_response(&ctx, &endpoint, &err.error);
}
};
@@ -661,7 +661,7 @@ pub async fn handle_responses_compact(
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
return build_codex_proxy_error_response(&ctx, &endpoint, &err.error);
}
};
@@ -926,6 +926,175 @@ async fn handle_codex_chat_error_response(
})
}
/// 把转发层(非上游响应)的失败构造成富化的 Codex 错误响应。
///
/// 与 `handle_codex_chat_error_response`(处理上游真实错误响应、复制上游头)不同,
/// 这里没有上游响应可参照,只产出一个 `application/json` 错误体。状态码走
/// `map_proxy_error_to_status`,该函数已与 `ProxyError::into_response` 对齐。
///
/// 注意:`endpoint` 经 `endpoint_with_query` 可能携带 query(如 `?beta=true`)并被
/// 原样写入错误体。当前 Codex 端点不在 query 里放凭证,故安全;若将来复用到
/// query 携带密钥的端点(如 Gemini 的 `?key=`),需先脱敏再回显。
fn build_codex_proxy_error_response(
ctx: &RequestContext,
endpoint: &str,
error: &ProxyError,
) -> Result<axum::response::Response, ProxyError> {
let status = axum::http::StatusCode::from_u16(map_proxy_error_to_status(error))
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
let body = codex_proxy_error_json(&ctx.provider.name, &ctx.request_model, endpoint, error);
let body = serde_json::to_vec(&body).map_err(|e| {
log::error!("[Codex] 序列化代理错误体失败: {e}");
ProxyError::Internal(format!("Failed to serialize proxy error: {e}"))
})?;
axum::response::Response::builder()
.status(status)
.header(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
)
.body(axum::body::Body::from(body))
.map_err(|e| {
log::error!("[Codex] 构建代理错误响应失败: {e}");
ProxyError::Internal(format!("Failed to build proxy error response: {e}"))
})
}
fn codex_proxy_error_json(
provider_name: &str,
request_model: &str,
endpoint: &str,
error: &ProxyError,
) -> Value {
let (mut body, upstream_status) = match error {
ProxyError::UpstreamError { status, body } => {
let parsed_body = body
.as_deref()
.map(|body| serde_json::from_str::<Value>(body).unwrap_or_else(|_| json!(body)));
(
transform_codex_chat::chat_error_to_response_error(parsed_body.as_ref()),
Some(*status),
)
}
_ => (
json!({
"error": {
"message": get_error_message(error),
"type": "proxy_error",
"code": codex_proxy_error_code(error),
"param": Value::Null,
}
}),
None,
),
};
let Some(error_obj) = body
.get_mut("error")
.and_then(|value| value.as_object_mut())
else {
return body;
};
let cause = error_obj
.get("message")
.and_then(|value| value.as_str())
.map(ToString::to_string)
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| get_error_message(error));
let status_fragment = upstream_status
.map(|status| format!("; upstream_status: HTTP {status}"))
.unwrap_or_default();
let message = format!(
"CC Switch local proxy failed while handling Codex endpoint {endpoint}. Provider: {provider_name}; model: {request_model}{status_fragment}; cause: {cause}"
);
error_obj.insert(
"message".to_string(),
Value::String(compact_error_message(&message, 1800)),
);
if error_obj
.get("type")
.and_then(|value| value.as_str())
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
error_obj.insert("type".to_string(), Value::String("proxy_error".to_string()));
}
if error_obj.get("code").map(Value::is_null).unwrap_or(true) {
error_obj.insert(
"code".to_string(),
Value::String(codex_proxy_error_code(error).to_string()),
);
}
if !error_obj.contains_key("param") {
error_obj.insert("param".to_string(), Value::Null);
}
error_obj.insert(
"provider".to_string(),
Value::String(provider_name.to_string()),
);
error_obj.insert(
"model".to_string(),
Value::String(request_model.to_string()),
);
// 仅用于 Codex 本地路由;不要复用到 query 可能携带凭证的端点。
error_obj.insert("endpoint".to_string(), Value::String(endpoint.to_string()));
if let Some(status) = upstream_status {
error_obj.insert(
"upstream_status".to_string(),
Value::Number(serde_json::Number::from(status)),
);
}
body
}
fn codex_proxy_error_code(error: &ProxyError) -> &'static str {
match error {
ProxyError::ForwardFailed(_) => "cc_switch_forward_failed",
ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => "cc_switch_timeout",
ProxyError::NoAvailableProvider => "cc_switch_no_available_provider",
ProxyError::AllProvidersCircuitOpen => "cc_switch_all_providers_circuit_open",
ProxyError::NoProvidersConfigured => "cc_switch_no_providers_configured",
ProxyError::MaxRetriesExceeded => "cc_switch_max_retries_exceeded",
ProxyError::ProviderUnhealthy(_) => "cc_switch_provider_unhealthy",
ProxyError::ConfigError(_) => "cc_switch_config_error",
ProxyError::TransformError(_) => "cc_switch_transform_error",
ProxyError::InvalidRequest(_) => "cc_switch_invalid_request",
ProxyError::AuthError(_) => "cc_switch_auth_error",
ProxyError::UpstreamError { .. } => "cc_switch_upstream_error",
ProxyError::DatabaseError(_) => "cc_switch_database_error",
ProxyError::Internal(_) => "cc_switch_internal_error",
ProxyError::AlreadyRunning
| ProxyError::NotRunning
| ProxyError::BindFailed(_)
| ProxyError::StopTimeout
| ProxyError::StopFailed(_) => "cc_switch_proxy_error",
}
}
fn compact_error_message(message: &str, max_chars: usize) -> String {
let normalized = message.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.chars().count() <= max_chars {
return normalized;
}
let truncated = normalized
.chars()
.take(max_chars)
.collect::<String>()
.trim_end()
.to_string();
format!("{truncated}…(truncated)")
}
// ============================================================================
// Gemini API 处理器
// ============================================================================
@@ -1176,7 +1345,10 @@ async fn log_usage(
#[cfg(test)]
mod tests {
use super::{responses_sse_to_response_value, should_use_claude_transform_streaming};
use super::{
codex_proxy_error_json, responses_sse_to_response_value,
should_use_claude_transform_streaming,
};
use crate::proxy::ProxyError;
#[test]
@@ -1263,4 +1435,38 @@ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n
assert!(responses_sse_to_response_value(sse).is_err());
}
#[test]
fn codex_proxy_forward_error_includes_context_and_cause() {
let error = ProxyError::ForwardFailed("连接失败: dns lookup failed".to_string());
let body = codex_proxy_error_json("DeepSeek", "deepseek-chat", "/responses", &error);
let message = body["error"]["message"].as_str().unwrap();
assert!(message.contains("CC Switch local proxy failed"));
assert!(message.contains("DeepSeek"));
assert!(message.contains("deepseek-chat"));
assert!(message.contains("/responses"));
assert!(message.contains("dns lookup failed"));
assert_eq!(body["error"]["code"], "cc_switch_forward_failed");
assert_eq!(body["error"]["provider"], "DeepSeek");
assert_eq!(body["error"]["model"], "deepseek-chat");
}
#[test]
fn codex_proxy_upstream_error_normalizes_nonstandard_body() {
let error = ProxyError::UpstreamError {
status: 502,
body: Some(
r#"{"base_resp":{"status_code":2013,"status_msg":"upstream gateway failed"}}"#
.to_string(),
),
};
let body = codex_proxy_error_json("MiniMax", "abab6.5s", "/responses", &error);
let message = body["error"]["message"].as_str().unwrap();
assert!(message.contains("upstream_status: HTTP 502"));
assert!(message.contains("upstream gateway failed"));
assert_eq!(body["error"]["code"], 2013);
assert_eq!(body["error"]["upstream_status"], 502);
}
}