Feature/error request logging (#401)

* feat(proxy): add error mapper for HTTP status code mapping

- Add error_mapper.rs module to map ProxyError to HTTP status codes
- Implement map_proxy_error_to_status() for error classification
- Implement get_error_message() for user-friendly error messages
- Support all error types: upstream, timeout, connection, provider failures
- Include comprehensive unit tests for all mappings

* feat(proxy): enhance error logging with context support

- Add log_error_with_context() method for detailed error recording
- Support streaming flag, session_id, and provider_type fields
- Remove dead_code warning from log_error() method
- Enable comprehensive error request tracking in database

* feat(proxy): implement error capture and logging in all handlers

- Capture and log all failed requests in handle_messages (Claude)
- Capture and log all failed requests in handle_gemini (Gemini)
- Capture and log all failed requests in handle_responses (Codex)
- Capture and log all failed requests in handle_chat_completions (Codex)
- Record error status codes, messages, and latency for all failures
- Generate unique session_id for each request
- Support both streaming and non-streaming error scenarios

* style: fix clippy warnings and typescript errors

- Add allow(dead_code) for CircuitBreaker::get_state (reserved for future)
- Fix all uninlined format string warnings (27 instances)
- Use inline format syntax for better readability
- Fix unused import and parameter warnings in ProviderActions.tsx
- Achieve zero warnings in both Rust and TypeScript

* style: apply code formatting

- Remove trailing whitespace in misc.rs
- Add trailing comma in App.tsx
- Format multi-line className in ProviderCard.tsx

* feat(proxy): add settings button to proxy panel

Add configuration buttons in both running and stopped states to
provide easy access to proxy settings dialog.

* fix(speedtest): skip client build for invalid inputs

* chore(clippy): fix uninlined format args

* Merge branch 'main' into feature/error-request-logging
This commit is contained in:
YoVinchen
2025-12-16 21:02:08 +08:00
committed by GitHub
parent e6654bd7f9
commit ec20ff4d8c
13 changed files with 327 additions and 106 deletions

View File

@@ -69,11 +69,7 @@ impl Database {
}
/// 添加供应商到故障转移队列末尾
pub fn add_to_failover_queue(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order
@@ -199,11 +195,8 @@ impl Database {
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM failover_queue WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}

View File

@@ -333,9 +333,7 @@ pub fn run() {
let app_state = AppState::new(db);
// 设置 AppHandle 用于代理故障转移时的 UI 更新
app_state
.proxy_service
.set_app_handle(app.handle().clone());
app_state.proxy_service.set_app_handle(app.handle().clone());
// ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)

View File

@@ -169,8 +169,7 @@ impl CircuitBreaker {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {})",
max_half_open_requests
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
false
}
@@ -239,9 +238,7 @@ impl CircuitBreaker {
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
// HalfOpen 状态下失败,立即转为 Open
log::warn!(
"Circuit breaker HalfOpen probe failed, transitioning to Open"
);
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
drop(config);
self.transition_to_open().await;
}
@@ -286,6 +283,7 @@ impl CircuitBreaker {
}
/// 获取当前状态
#[allow(dead_code)]
pub async fn get_state(&self) -> CircuitState {
*self.state.read().await
}

View File

@@ -0,0 +1,110 @@
//! 错误类型到 HTTP 状态码的映射
//!
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
use super::ProxyError;
/// 将 ProxyError 映射到 HTTP 状态码
///
/// 映射规则:
/// - 上游错误:直接使用上游返回的状态码
/// - 超时504 Gateway Timeout
/// - 连接失败502 Bad Gateway
/// - 无可用 Provider503 Service Unavailable
/// - 重试耗尽503 Service Unavailable
/// - 其他错误500 Internal Server Error
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
match error {
// 上游错误:使用实际状态码
ProxyError::UpstreamError { status, .. } => *status,
// 超时错误504 Gateway Timeout
ProxyError::Timeout(_) => 504,
// 转发失败/连接失败502 Bad Gateway
ProxyError::ForwardFailed(_) => 502,
// 无可用 Provider503 Service Unavailable
ProxyError::NoAvailableProvider => 503,
// 重试耗尽503 Service Unavailable
ProxyError::MaxRetriesExceeded => 503,
// Provider 不健康503 Service Unavailable
ProxyError::ProviderUnhealthy(_) => 503,
// 数据库错误500 Internal Server Error
ProxyError::DatabaseError(_) => 500,
// 转换错误500 Internal Server Error
ProxyError::TransformError(_) => 500,
// 其他未知错误500 Internal Server Error
_ => 500,
}
}
/// 将 ProxyError 转换为用户友好的错误消息
pub fn get_error_message(error: &ProxyError) -> String {
match error {
ProxyError::UpstreamError { status, body } => {
if let Some(body) = body {
format!("上游错误 ({status}): {body}")
} else {
format!("上游错误 ({status})")
}
}
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"),
_ => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_upstream_error() {
let error = ProxyError::UpstreamError {
status: 401,
body: Some("Unauthorized".to_string()),
};
assert_eq!(map_proxy_error_to_status(&error), 401);
}
#[test]
fn test_map_timeout_error() {
let error = ProxyError::Timeout("Request timeout".to_string());
assert_eq!(map_proxy_error_to_status(&error), 504);
}
#[test]
fn test_map_connection_error() {
let error = ProxyError::ForwardFailed("Connection refused".to_string());
assert_eq!(map_proxy_error_to_status(&error), 502);
}
#[test]
fn test_map_no_provider_error() {
let error = ProxyError::NoAvailableProvider;
assert_eq!(map_proxy_error_to_status(&error), 503);
}
#[test]
fn test_get_error_message() {
let error = ProxyError::UpstreamError {
status: 500,
body: Some("Internal Server Error".to_string()),
};
let msg = get_error_message(&error);
assert!(msg.contains("上游错误"));
assert!(msg.contains("500"));
assert!(msg.contains("Internal Server Error"));
}
}

View File

@@ -48,17 +48,13 @@ impl FailoverSwitchManager {
provider_id: &str,
provider_name: &str,
) -> Result<bool, AppError> {
let switch_key = format!("{}:{}", app_type, provider_id);
let switch_key = format!("{app_type}:{provider_id}");
// 去重检查:如果相同切换已在进行中,跳过
{
let mut pending = self.pending_switches.write().await;
if pending.contains(&switch_key) {
log::debug!(
"[Failover] 切换已在进行中,跳过: {} -> {}",
app_type,
provider_id
);
log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}");
return Ok(false);
}
pending.insert(switch_key.clone());
@@ -85,19 +81,14 @@ impl FailoverSwitchManager {
provider_id: &str,
provider_name: &str,
) -> Result<bool, AppError> {
log::info!(
"[Failover] 开始切换供应商: {} -> {} ({})",
app_type,
provider_name,
provider_id
);
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
// 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?;
// 2. 更新本地 settings设备级
let app_type_enum = crate::app_config::AppType::from_str(app_type)
.map_err(|_| AppError::Message(format!("无效的应用类型: {}", app_type)))?;
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
// 3. 更新托盘菜单和发射事件
@@ -136,12 +127,7 @@ impl FailoverSwitchManager {
}
}
log::info!(
"[Failover] 供应商切换完成: {} -> {} ({})",
app_type,
provider_name,
provider_id
);
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
Ok(true)
}

View File

@@ -330,9 +330,8 @@ impl RequestForwarder {
status.failed_requests += 1;
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
return Err(ProxyError::NoAvailableProvider);

View File

@@ -8,14 +8,13 @@
//! - Claude 的格式转换逻辑保留在此文件(独有功能)
use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{
create_logged_passthrough_stream, process_response, SseUsageCollector,
},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
server::ProxyState,
types::*,
usage::parser::TokenUsage,
@@ -62,8 +61,7 @@ pub async fn handle_messages(
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let ctx =
RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
// 检查是否需要格式转换OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
@@ -83,7 +81,7 @@ pub async fn handle_messages(
// 转发请求
let forwarder = ctx.create_forwarder(&state);
let response = forwarder
let response = match forwarder
.forward_with_retry(
&AppType::Claude,
"/v1/messages",
@@ -91,7 +89,14 @@ pub async fn handle_messages(
headers,
ctx.get_providers(),
)
.await?;
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
let status = response.status();
log::info!("[Claude] 上游响应状态: {status}");
@@ -304,7 +309,7 @@ pub async fn handle_chat_completions(
);
let forwarder = ctx.create_forwarder(&state);
let response = forwarder
let response = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/chat/completions",
@@ -312,7 +317,14 @@ pub async fn handle_chat_completions(
headers,
ctx.get_providers(),
)
.await?;
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Codex] 上游响应状态: {}", response.status());
@@ -327,8 +339,13 @@ pub async fn handle_responses(
) -> Result<axum::response::Response, ProxyError> {
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state);
let response = forwarder
let response = match forwarder
.forward_with_retry(
&AppType::Codex,
"/v1/responses",
@@ -336,7 +353,14 @@ pub async fn handle_responses(
headers,
ctx.get_providers(),
)
.await?;
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Codex] 上游响应状态: {}", response.status());
@@ -365,10 +389,15 @@ pub async fn handle_gemini(
.map(|pq| pq.as_str())
.unwrap_or(uri.path());
log::info!("[Gemini] 请求端点: {}", endpoint);
log::info!("[Gemini] 请求端点: {endpoint}");
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state);
let response = forwarder
let response = match forwarder
.forward_with_retry(
&AppType::Gemini,
endpoint,
@@ -376,7 +405,14 @@ pub async fn handle_gemini(
headers,
ctx.get_providers(),
)
.await?;
.await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Gemini] 上游响应状态: {}", response.status());
@@ -387,6 +423,35 @@ pub async fn handle_gemini(
// 使用量记录(保留用于 Claude 转换逻辑)
// ============================================================================
fn log_forward_error(
state: &ProxyState,
ctx: &RequestContext,
is_streaming: bool,
error: &ProxyError,
) {
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
let status_code = map_proxy_error_to_status(error);
let error_message = get_error_message(error);
let request_id = uuid::Uuid::new_v4().to_string();
if let Err(e) = logger.log_error_with_context(
request_id.clone(),
ctx.provider.id.clone(),
ctx.app_type_str.to_string(),
ctx.request_model.clone(),
status_code,
error_message,
ctx.latency_ms(),
is_streaming,
Some(request_id),
None,
) {
log::warn!("记录失败请求日志失败: {e}");
}
}
/// 记录请求使用量
#[allow(clippy::too_many_arguments)]
async fn log_usage(

View File

@@ -4,6 +4,7 @@
pub mod circuit_breaker;
pub mod error;
pub mod error_mapper;
pub(crate) mod failover_switch;
mod forwarder;
pub mod handler_config;

View File

@@ -102,8 +102,7 @@ impl ProviderRouter {
if result.is_empty() {
return Err(AppError::Config(format!(
"No available provider for {} (all circuit breakers open or no providers configured)",
app_type
"No available provider for {app_type} (all circuit breakers open or no providers configured)"
)));
}
@@ -199,7 +198,7 @@ impl ProviderRouter {
breaker.update_config(config.clone()).await;
}
log::info!("已更新 {} 个熔断器的配置", count);
log::info!("已更新 {count} 个熔断器的配置");
}
/// 获取熔断器状态

View File

@@ -107,6 +107,8 @@ impl<'a> UsageLogger<'a> {
}
/// 记录失败的请求
///
/// 用于记录无法从上游获取 usage 信息的失败请求
#[allow(dead_code, clippy::too_many_arguments)]
pub fn log_error(
&self,
@@ -138,6 +140,43 @@ impl<'a> UsageLogger<'a> {
self.log_request(&log)
}
/// 记录失败的请求(带更多上下文信息)
///
/// 相比 log_error这个方法接受更多参数以提供完整的请求上下文
#[allow(clippy::too_many_arguments)]
pub fn log_error_with_context(
&self,
request_id: String,
provider_id: String,
app_type: String,
model: String,
status_code: u16,
error_message: String,
latency_ms: u64,
is_streaming: bool,
session_id: Option<String>,
provider_type: Option<String>,
) -> Result<(), AppError> {
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
first_token_ms: None,
status_code,
error_message: Some(error_message),
session_id,
provider_type,
is_streaming,
cost_multiplier: "1.0".to_string(),
};
self.log_request(&log)
}
/// 获取模型定价
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
let conn = crate::database::lock_conn!(self.db.conn);

View File

@@ -142,8 +142,7 @@ impl ProxyService {
log::warn!("同步 Claude Token 到数据库失败: {e}");
} else {
log::info!(
"已同步 Claude Token 到数据库 (provider: {})",
provider_id
"已同步 Claude Token 到数据库 (provider: {provider_id})"
);
}
}
@@ -182,8 +181,7 @@ impl ProxyService {
log::warn!("同步 Codex Token 到数据库失败: {e}");
} else {
log::info!(
"已同步 Codex Token 到数据库 (provider: {})",
provider_id
"已同步 Codex Token 到数据库 (provider: {provider_id})"
);
}
}
@@ -222,8 +220,7 @@ impl ProxyService {
log::warn!("同步 Gemini Token 到数据库失败: {e}");
} else {
log::info!(
"已同步 Gemini Token 到数据库 (provider: {})",
provider_id
"已同步 Gemini Token 到数据库 (provider: {provider_id})"
);
}
}
@@ -354,7 +351,7 @@ impl ProxyService {
});
}
self.write_claude_live(&live_config)?;
log::info!("Claude Live 配置已接管,代理地址: {}", proxy_url);
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
}
// Codex: 修改 config.toml 的 base_urlauth.json 的 OPENAI_API_KEY代理会注入真实 Token
@@ -373,7 +370,7 @@ impl ProxyService {
live_config["config"] = json!(updated_config);
self.write_codex_live(&live_config)?;
log::info!("Codex Live 配置已接管,代理地址: {}", proxy_url);
log::info!("Codex Live 配置已接管,代理地址: {proxy_url}");
}
// Gemini: 修改 GOOGLE_GEMINI_BASE_URL使用占位符替代真实 Token代理会注入真实 Token
@@ -389,7 +386,7 @@ impl ProxyService {
});
}
self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {}", proxy_url);
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
}
Ok(())
@@ -513,11 +510,7 @@ impl ProxyService {
.set_current_provider(app_type_enum.as_str(), provider_id)
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
log::info!(
"代理模式:已切换 {} 的目标供应商为 {}",
app_type,
provider_id
);
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
Ok(())
}
@@ -721,11 +714,7 @@ impl ProxyService {
server
.reset_provider_circuit_breaker(provider_id, app_type)
.await;
log::info!(
"已重置 Provider {} (app: {}) 的熔断器",
provider_id,
app_type
);
log::info!("已重置 Provider {provider_id} (app: {app_type}) 的熔断器");
}
Ok(())
}

View File

@@ -31,40 +31,51 @@ impl SpeedtestService {
return Ok(vec![]);
}
let mut results: Vec<Option<EndpointLatency>> = vec![None; urls.len()];
let mut valid_targets = Vec::new();
for (idx, raw_url) in urls.into_iter().enumerate() {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
results[idx] = Some(EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
});
continue;
}
match Url::parse(&trimmed) {
Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)),
Err(err) => {
results[idx] = Some(EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
});
}
}
}
if valid_targets.is_empty() {
return Ok(results.into_iter().flatten().collect::<Vec<_>>());
}
let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?;
let tasks = urls.into_iter().map(|raw_url| {
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone();
async move {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
return EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
};
}
let parsed_url = match Url::parse(&trimmed) {
Ok(url) => url,
Err(err) => {
return EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
};
}
};
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await;
// 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now();
match client.get(parsed_url).send().await {
let latency = match client.get(parsed_url).send().await {
Ok(resp) => EndpointLatency {
url: trimmed,
latency: Some(start.elapsed().as_millis()),
@@ -88,11 +99,17 @@ impl SpeedtestService {
error: Some(error_message),
}
}
}
};
(idx, latency)
}
});
Ok(join_all(tasks).await)
for (idx, latency) in join_all(tasks).await {
results[idx] = Some(latency);
}
Ok(results.into_iter().flatten().collect::<Vec<_>>())
}
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {