mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +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 状态码的映射
|
//! 错误类型到 HTTP 状态码的映射
|
||||||
//!
|
//!
|
||||||
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
|
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录和手动构建错误响应
|
||||||
|
|
||||||
use super::ProxyError;
|
use super::ProxyError;
|
||||||
|
|
||||||
@@ -12,14 +12,21 @@ use super::ProxyError;
|
|||||||
/// - 连接失败:502 Bad Gateway
|
/// - 连接失败:502 Bad Gateway
|
||||||
/// - 无可用 Provider:503 Service Unavailable
|
/// - 无可用 Provider:503 Service Unavailable
|
||||||
/// - 重试耗尽:503 Service Unavailable
|
/// - 重试耗尽:503 Service Unavailable
|
||||||
|
/// - 认证错误:401 Unauthorized
|
||||||
|
/// - 配置/请求错误:400 Bad Request
|
||||||
|
/// - 转换错误:422 Unprocessable Entity
|
||||||
/// - 其他错误:500 Internal Server Error
|
/// - 其他错误:500 Internal Server Error
|
||||||
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
||||||
match error {
|
match error {
|
||||||
|
// 服务状态错误:与 IntoResponse 保持一致
|
||||||
|
ProxyError::AlreadyRunning => 409,
|
||||||
|
ProxyError::NotRunning => 503,
|
||||||
|
|
||||||
// 上游错误:使用实际状态码
|
// 上游错误:使用实际状态码
|
||||||
ProxyError::UpstreamError { status, .. } => *status,
|
ProxyError::UpstreamError { status, .. } => *status,
|
||||||
|
|
||||||
// 超时错误:504 Gateway Timeout
|
// 超时错误:504 Gateway Timeout
|
||||||
ProxyError::Timeout(_) => 504,
|
ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => 504,
|
||||||
|
|
||||||
// 转发失败/连接失败:502 Bad Gateway
|
// 转发失败/连接失败:502 Bad Gateway
|
||||||
ProxyError::ForwardFailed(_) => 502,
|
ProxyError::ForwardFailed(_) => 502,
|
||||||
@@ -39,11 +46,17 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
|||||||
// Provider 不健康:503 Service Unavailable
|
// Provider 不健康:503 Service Unavailable
|
||||||
ProxyError::ProviderUnhealthy(_) => 503,
|
ProxyError::ProviderUnhealthy(_) => 503,
|
||||||
|
|
||||||
|
// 配置错误/无效请求:400 Bad Request
|
||||||
|
ProxyError::ConfigError(_) | ProxyError::InvalidRequest(_) => 400,
|
||||||
|
|
||||||
|
// 认证错误:401 Unauthorized
|
||||||
|
ProxyError::AuthError(_) => 401,
|
||||||
|
|
||||||
// 数据库错误:500 Internal Server Error
|
// 数据库错误:500 Internal Server Error
|
||||||
ProxyError::DatabaseError(_) => 500,
|
ProxyError::DatabaseError(_) => 500,
|
||||||
|
|
||||||
// 转换错误:500 Internal Server Error
|
// 转换错误:422 Unprocessable Entity
|
||||||
ProxyError::TransformError(_) => 500,
|
ProxyError::TransformError(_) => 422,
|
||||||
|
|
||||||
// 其他未知错误:500 Internal Server Error
|
// 其他未知错误:500 Internal Server Error
|
||||||
_ => 500,
|
_ => 500,
|
||||||
@@ -104,6 +117,30 @@ mod tests {
|
|||||||
assert_eq!(map_proxy_error_to_status(&error), 503);
|
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]
|
#[test]
|
||||||
fn test_get_error_message() {
|
fn test_get_error_message() {
|
||||||
let error = ProxyError::UpstreamError {
|
let error = ProxyError::UpstreamError {
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ pub async fn handle_chat_completions(
|
|||||||
ctx.provider = provider;
|
ctx.provider = provider;
|
||||||
}
|
}
|
||||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
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;
|
ctx.provider = provider;
|
||||||
}
|
}
|
||||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
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;
|
ctx.provider = provider;
|
||||||
}
|
}
|
||||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
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 处理器
|
// Gemini API 处理器
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1176,7 +1345,10 @@ async fn log_usage(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
use crate::proxy::ProxyError;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1263,4 +1435,38 @@ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n
|
|||||||
|
|
||||||
assert!(responses_sse_to_response_value(sse).is_err());
|
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