mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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:
@@ -1,6 +1,6 @@
|
||||
//! 错误类型到 HTTP 状态码的映射
|
||||
//!
|
||||
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
|
||||
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录和手动构建错误响应
|
||||
|
||||
use super::ProxyError;
|
||||
|
||||
@@ -12,14 +12,21 @@ use super::ProxyError;
|
||||
/// - 连接失败:502 Bad Gateway
|
||||
/// - 无可用 Provider:503 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user