feat: classify stream check errors with color-coded toasts

Distinguish between "provider rejects probe" (yellow warning) and
"genuinely broken" (red error) in health check results.

Backend: add AppError::HttpStatus variant to carry structured HTTP
status codes, populate http_status on error results, classify codes
into short labels (e.g. "Auth rejected (401)"), and truncate overly
long response bodies.

Frontend: route 401/403/400/429/5xx to toast.warning with localized
hints explaining the error may not indicate actual unusability; route
404/402/connection errors to toast.error. Add i18n keys for all three
locales (zh/en/ja).

Also deduplicate check_once by reusing build_stream_check_result.
This commit is contained in:
Jason
2026-04-14 17:11:13 +08:00
parent 8b851dc602
commit 689ca08409
8 changed files with 145 additions and 66 deletions
+18 -9
View File
@@ -116,15 +116,24 @@ pub async fn stream_check_all_providers(
claude_api_format_override,
)
.await
.unwrap_or_else(|e| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
.unwrap_or_else(|e| {
let (http_status, message) = match &e {
crate::error::AppError::HttpStatus { status, .. } => (
Some(*status),
StreamCheckService::classify_http_status(*status).to_string(),
),
_ => (None, e.to_string()),
};
StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message,
response_time_ms: None,
http_status,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
}
});
let _ = state
+2
View File
@@ -44,6 +44,8 @@ pub enum AppError {
McpValidation(String),
#[error("{0}")]
Message(String),
#[error("HTTP {status}: {body}")]
HttpStatus { status: u16, body: String },
#[error("{zh} ({en})")]
Localized {
key: &'static str,
+60 -41
View File
@@ -271,34 +271,11 @@ impl StreamCheckService {
};
let response_time = start.elapsed().as_millis() as u64;
let tested_at = chrono::Utc::now().timestamp();
match result {
Ok((status_code, model)) => {
let health_status =
Self::determine_status(response_time, config.degraded_threshold_ms);
Ok(StreamCheckResult {
status: health_status,
success: true,
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
tested_at,
retry_count: 0,
})
}
Err(e) => Ok(StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: String::new(),
tested_at,
retry_count: 0,
}),
}
Ok(Self::build_stream_check_result(
result,
response_time,
config.degraded_threshold_ms,
))
}
/// Claude 流式检查
@@ -475,7 +452,7 @@ impl StreamCheckService {
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
return Err(Self::http_status_error(status, error_text));
}
// 流式读取:只需首个 chunk
@@ -555,7 +532,7 @@ impl StreamCheckService {
if i == 0 && status == 404 && urls.len() > 1 {
continue;
}
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
return Err(Self::http_status_error(status, error_text));
}
let mut stream = response.bytes_stream();
@@ -630,7 +607,7 @@ impl StreamCheckService {
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
return Err(Self::http_status_error(status, error_text));
}
let mut stream = response.bytes_stream();
@@ -717,16 +694,25 @@ impl StreamCheckService {
tested_at,
retry_count: 0,
},
Err(e) => StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: String::new(),
tested_at,
retry_count: 0,
},
Err(e) => {
let (http_status, message) = match &e {
AppError::HttpStatus { status, .. } => (
Some(*status),
Self::classify_http_status(*status).to_string(),
),
_ => (None, e.to_string()),
};
StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message,
response_time_ms: Some(response_time),
http_status,
model_used: String::new(),
tested_at,
retry_count: 0,
}
}
}
}
@@ -1124,6 +1110,39 @@ impl StreamCheckService {
}
}
/// 构造 HTTP 状态码错误,截断过长的响应体
fn http_status_error(status: u16, body: String) -> AppError {
let body = if body.len() > 200 {
// 安全截断:找到 200 字节内最近的 char 边界
let mut end = 200;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
format!("{}", &body[..end])
} else {
body
};
AppError::HttpStatus { status, body }
}
/// 将 HTTP 状态码映射为简短的分类标签
pub(crate) fn classify_http_status(status: u16) -> &'static str {
match status {
400 => "Bad request (400)",
401 => "Auth rejected (401)",
402 => "Payment required (402)",
403 => "Access denied (403)",
404 => "Not found (404)",
429 => "Rate limited (429)",
500 => "Internal server error (500)",
502 => "Bad gateway (502)",
503 => "Service unavailable (503)",
504 => "Gateway timeout (504)",
s if (500..600).contains(&s) => "Server error",
_ => "HTTP error",
}
}
fn resolve_test_model(
app_type: &AppType,
provider: &Provider,
@@ -1,11 +1,6 @@
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import {
ChevronDown,
ChevronRight,
FlaskConical,
Coins,
} from "lucide-react";
import { ChevronDown, ChevronRight, FlaskConical, Coins } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
+31 -7
View File
@@ -47,13 +47,37 @@ export function useStreamCheck(appId: AppId) {
// 降级状态也重置熔断器,因为至少能通信
resetCircuitBreaker.mutate({ providerId, appType: appId });
} else {
toast.error(
t("streamCheck.failed", {
providerName: providerName,
message: result.message,
defaultValue: `${providerName} 检查失败: ${result.message}`,
}),
);
const httpStatus = result.httpStatus;
const hintKey = httpStatus
? `streamCheck.httpHint.${httpStatus >= 500 ? "5xx" : httpStatus}`
: null;
const description =
(hintKey ? t(hintKey, { defaultValue: "" }) : "") || undefined;
// 401/403/400 = 检查被拒(供应商可能正常);429/5xx = 临时问题
const isProbeRejection =
httpStatus != null &&
([401, 403, 400, 429].includes(httpStatus) || httpStatus >= 500);
if (isProbeRejection) {
toast.warning(
t("streamCheck.rejected", {
providerName: providerName,
message: result.message,
defaultValue: `${providerName} 检查被拒: ${result.message}`,
}),
{ description, duration: 8000, closeButton: true },
);
} else {
toast.error(
t("streamCheck.failed", {
providerName: providerName,
message: result.message,
defaultValue: `${providerName} 检查失败: ${result.message}`,
}),
{ description, duration: 8000, closeButton: true },
);
}
}
return result;
+11 -1
View File
@@ -2129,7 +2129,17 @@
"operational": "{{providerName}} is operational ({{responseTimeMs}}ms)",
"degraded": "{{providerName}} is slow ({{responseTimeMs}}ms)",
"failed": "{{providerName}} check failed: {{message}}",
"error": "{{providerName}} check error: {{error}}"
"rejected": "{{providerName}} check rejected: {{message}}",
"error": "{{providerName}} check error: {{error}}",
"httpHint": {
"400": "Provider rejected request format. Health check probe may differ from actual usage.",
"401": "API key may be invalid, or provider uses OAuth auth. Check failure doesn't mean it's unusable.",
"402": "Account quota or billing issue.",
"403": "Provider blocked this request. Some verify client identity; may work fine in actual use.",
"404": "Endpoint not found. Check base URL and API path.",
"429": "Too many requests. Try again later.",
"5xx": "Provider internal error. Likely temporary."
}
},
"proxyConfig": {
"proxyEnabled": "Routing Master Switch",
+11 -1
View File
@@ -2129,7 +2129,17 @@
"operational": "{{providerName}} は正常に動作しています ({{responseTimeMs}}ms)",
"degraded": "{{providerName}} の応答が遅いです ({{responseTimeMs}}ms)",
"failed": "{{providerName}} のチェックに失敗しました: {{message}}",
"error": "{{providerName}} のチェックでエラーが発生しました: {{error}}"
"rejected": "{{providerName}} のチェックが拒否されました: {{message}}",
"error": "{{providerName}} のチェックでエラーが発生しました: {{error}}",
"httpHint": {
"400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。",
"401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。",
"402": "アカウントの利用枠または請求の問題です。",
"403": "プロバイダーがリクエストを拒否しました。実際の使用では正常に動作する場合があります。",
"404": "エンドポイントが見つかりません。Base URLとAPIパスを確認してください。",
"429": "リクエストが多すぎます。しばらくしてからお試しください。",
"5xx": "プロバイダーの内部エラーです。一時的な問題の可能性が高いです。"
}
},
"proxyConfig": {
"proxyEnabled": "ルーティング総スイッチ",
+11 -1
View File
@@ -2130,7 +2130,17 @@
"operational": "{{providerName}} 运行正常 ({{responseTimeMs}}ms)",
"degraded": "{{providerName}} 响应较慢 ({{responseTimeMs}}ms)",
"failed": "{{providerName}} 检查失败: {{message}}",
"error": "{{providerName}} 检查出错: {{error}}"
"rejected": "{{providerName}} 检查被拒: {{message}}",
"error": "{{providerName}} 检查出错: {{error}}",
"httpHint": {
"400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。",
"401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。",
"402": "账户配额或计费问题。",
"403": "供应商拒绝了此请求。部分供应商会校验客户端身份,检查失败但实际使用可能正常。",
"404": "接口地址不存在,请检查 Base URL 和 API 路径。",
"429": "请求频率过高,请稍后重试。",
"5xx": "供应商内部错误,通常是临时问题。"
}
},
"proxyConfig": {
"proxyEnabled": "路由总开关",