mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
fix(usage): reject transient transport failures so retry and keep-last-good work
Usage/quota queries frequently showed spurious "query failed" states that manual refresh could not clear (#3820). Root cause: all transport-level failures were folded into Ok(success:false), so react-query's retry never fired and the failure body poisoned the cache as regular data. Backend: - balance/coding_plan/subscription services now return Err for send failures and body-read failures (read body via bytes() before serde_json::from_slice; reqwest's json() wraps read errors as Decode, making error-kind checks on it dead code). Auth/4xx/parse errors stay Ok(success:false) and surface immediately. - Script path maps transient AppError keys (request_failed / read_response_failed) to Err; Volcengine adds a Transient call variant. - Command layer skips snapshot persistence, usage-cache-updated emit and tray refresh on Err so the cache bridge cannot overwrite retained data. - Expired-credential retry propagates transient errors instead of rewriting them as "OAuth token has expired". Frontend: - resolveDisplayUsage generalized to subscription quotas; 5th param is now an options object with a `rejected` flag: stale success data retained by react-query across rejections is re-anchored to dataUpdatedAt and expires through the same 10-minute keep-last-good window instead of being shown indefinitely (a total outage used to mask longer than a single 5xx). - useSubscriptionQuota / useCodexOauthQuota gain keep-last-good with per-scope snapshot reset; rejected queries with no displayable value synthesize a failure placeholder carrying the real error message so footers keep rendering a retry entry point. - HTTP 429 is classified transient (retry-later) alongside 5xx. - UsageScriptModal surfaces string rejections via extractErrorMessage. Tests: 6 backend behavior tests drive real HTTP against a local listener to pin the Err/Ok channel semantics; frontend suite extends keepLastGoodUsage coverage (405 passing).
This commit is contained in:
@@ -99,7 +99,7 @@ fn make_error(msg: String) -> SubscriptionQuota {
|
||||
|
||||
// ── Kimi For Coding ─────────────────────────────────────────
|
||||
|
||||
async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
async fn query_kimi(api_key: &str) -> Result<SubscriptionQuota, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
@@ -112,12 +112,12 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
@@ -126,17 +126,23 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||||
};
|
||||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
@@ -187,7 +193,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
});
|
||||
}
|
||||
|
||||
SubscriptionQuota {
|
||||
Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
@@ -196,7 +202,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||
@@ -307,7 +313,7 @@ fn zhipu_quota_base(base_url: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
async fn query_zhipu(base_url: &str, api_key: &str) -> Result<SubscriptionQuota, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
let url = format!(
|
||||
"{}/api/monitor/usage/quota/limit",
|
||||
@@ -325,12 +331,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
@@ -339,17 +345,23 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||||
};
|
||||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
@@ -358,12 +370,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
.get("msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error: {msg}"));
|
||||
return Ok(make_error(format!("API error: {msg}")));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => return make_error("Missing 'data' field in response".to_string()),
|
||||
None => return Ok(make_error("Missing 'data' field in response".to_string())),
|
||||
};
|
||||
|
||||
let tiers = parse_zhipu_token_tiers(data);
|
||||
@@ -374,7 +386,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
SubscriptionQuota {
|
||||
Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: level,
|
||||
@@ -383,12 +395,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── MiniMax ─────────────────────────────────────────────────
|
||||
|
||||
async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
async fn query_minimax(api_key: &str, is_cn: bool) -> Result<SubscriptionQuota, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let api_domain = if is_cn {
|
||||
@@ -408,12 +420,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
@@ -422,17 +434,23 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||||
};
|
||||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
@@ -446,14 +464,14 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
.get("status_msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error (code {status_code}): {msg}"));
|
||||
return Ok(make_error(format!("API error (code {status_code}): {msg}")));
|
||||
}
|
||||
}
|
||||
|
||||
// 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比
|
||||
let tiers = parse_minimax_tiers(&body);
|
||||
|
||||
SubscriptionQuota {
|
||||
Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
@@ -462,12 +480,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── ZenMux ──────────────────────────────────────────────────
|
||||
|
||||
async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
async fn query_zenmux(base_url: &str, api_key: &str) -> Result<SubscriptionQuota, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
let resp = client
|
||||
@@ -480,12 +498,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return make_error(format!("Network error: {e}")),
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return SubscriptionQuota {
|
||||
return Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Expired,
|
||||
credential_message: Some("Invalid API key".to_string()),
|
||||
@@ -494,17 +512,23 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||
queried_at: Some(now_millis()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
||||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||||
};
|
||||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
||||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||
};
|
||||
|
||||
// 检查业务级别错误
|
||||
@@ -513,12 +537,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return make_error(format!("API error: {msg}"));
|
||||
return Ok(make_error(format!("API error: {msg}")));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => return make_error("Missing 'data' field in response".to_string()),
|
||||
None => return Ok(make_error("Missing 'data' field in response".to_string())),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
@@ -581,7 +605,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
String::new()
|
||||
};
|
||||
|
||||
SubscriptionQuota {
|
||||
Ok(SubscriptionQuota {
|
||||
tool: "coding_plan".to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: if plan_info.is_empty() {
|
||||
@@ -594,7 +618,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。
|
||||
@@ -690,8 +714,11 @@ enum VolcCall {
|
||||
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
|
||||
/// 共用凭据,命中即停。
|
||||
Auth(String),
|
||||
/// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。
|
||||
/// 非鉴权 HTTP 错误 / 响应体非法 JSON——记录后可继续尝试另一个 plan。
|
||||
Soft(String),
|
||||
/// 瞬时传输失败(网络/超时/读体中断)——同 host 的另一个 plan 大概率同样
|
||||
/// 失败,调用方应立即以 `Err` 传播(前端 reject → retry + 保留上次成功值)。
|
||||
Transient(String),
|
||||
}
|
||||
|
||||
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
|
||||
@@ -889,7 +916,7 @@ async fn volcengine_openapi_call(
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => return VolcCall::Soft(format!("Network error: {e}")),
|
||||
Err(e) => return VolcCall::Transient(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
@@ -916,7 +943,13 @@ async fn volcengine_openapi_call(
|
||||
return VolcCall::Soft(format!("API error (HTTP {status}): {raw}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
// 同 Bearer 路径:先 bytes() 再解析——读体失败是瞬时(Transient),解析失败
|
||||
// 是确定性(Soft)。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return VolcCall::Transient(format!("Failed to read response: {e}")),
|
||||
};
|
||||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")),
|
||||
};
|
||||
@@ -1058,7 +1091,7 @@ async fn query_volcengine(
|
||||
base_url: &str,
|
||||
access_key_id: &str,
|
||||
secret_access_key: &str,
|
||||
) -> SubscriptionQuota {
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
let region = volcengine_region(base_url);
|
||||
let mut soft_errors: Vec<String> = Vec::new();
|
||||
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
|
||||
@@ -1071,7 +1104,8 @@ async fn query_volcengine(
|
||||
|
||||
// 1) Agent Plan:GetAFPUsage
|
||||
match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await {
|
||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
||||
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
|
||||
VolcCall::Transient(detail) => return Err(format!("GetAFPUsage: {detail}")),
|
||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")),
|
||||
VolcCall::Body(body) => {
|
||||
let result = body.get("Result").unwrap_or(&body);
|
||||
@@ -1083,7 +1117,7 @@ async fn query_volcengine(
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| format!("Agent Plan {s}"));
|
||||
return volcengine_success(tiers, plan);
|
||||
return Ok(volcengine_success(tiers, plan));
|
||||
}
|
||||
empty_responses.push(summarize("GetAFPUsage", &body));
|
||||
}
|
||||
@@ -1098,32 +1132,33 @@ async fn query_volcengine(
|
||||
)
|
||||
.await
|
||||
{
|
||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
||||
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
|
||||
VolcCall::Transient(detail) => return Err(format!("GetCodingPlanUsage: {detail}")),
|
||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")),
|
||||
VolcCall::Body(body) => {
|
||||
let result = body.get("Result").unwrap_or(&body);
|
||||
let tiers = parse_coding_plan_tiers(result);
|
||||
if !tiers.is_empty() {
|
||||
return volcengine_success(tiers, Some("Coding Plan".to_string()));
|
||||
return Ok(volcengine_success(tiers, Some("Coding Plan".to_string())));
|
||||
}
|
||||
empty_responses.push(summarize("GetCodingPlanUsage", &body));
|
||||
}
|
||||
}
|
||||
|
||||
if !soft_errors.is_empty() {
|
||||
make_error(soft_errors.join("; "))
|
||||
Ok(make_error(soft_errors.join("; ")))
|
||||
} else if !empty_responses.is_empty() {
|
||||
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
|
||||
// 便于核对真实字段名/包裹层,或确认确实未订阅。
|
||||
make_error(format!(
|
||||
Ok(make_error(format!(
|
||||
"No active subscription found (signature OK). Raw: {}",
|
||||
empty_responses.join(" || ")
|
||||
))
|
||||
)))
|
||||
} else {
|
||||
make_error(
|
||||
Ok(make_error(
|
||||
"No active Agent Plan or Coding Plan subscription found for this credential"
|
||||
.to_string(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1143,6 +1178,9 @@ fn coding_plan_not_found(error: &str) -> SubscriptionQuota {
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询编程套餐额度。瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject →
|
||||
/// retry + 保留上次成功值);确定性失败(凭据缺失/未知域名/鉴权/非 2xx/业务错误)
|
||||
/// 返回 `Ok(success:false)` 立即透出文案。判定按 reqwest 错误种类在折叠点完成。
|
||||
pub async fn get_coding_plan_quota(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
@@ -1165,7 +1203,7 @@ pub async fn get_coding_plan_quota(
|
||||
"Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)",
|
||||
));
|
||||
}
|
||||
return Ok(query_volcengine(base_url, ak, sk).await);
|
||||
return query_volcengine(base_url, ak, sk).await;
|
||||
}
|
||||
|
||||
// 其余供应商:数据面 Bearer api_key。
|
||||
@@ -1174,7 +1212,7 @@ pub async fn get_coding_plan_quota(
|
||||
return Ok(coding_plan_not_found("API key is empty"));
|
||||
}
|
||||
|
||||
let quota = match provider {
|
||||
match provider {
|
||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
|
||||
query_zhipu(base_url, api_key).await
|
||||
@@ -1186,9 +1224,7 @@ pub async fn get_coding_plan_quota(
|
||||
CodingPlanProvider::Volcengine => {
|
||||
unreachable!("volcengine handled via AK/SK branch above")
|
||||
}
|
||||
};
|
||||
|
||||
Ok(quota)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1806,4 +1842,140 @@ mod tests {
|
||||
let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} });
|
||||
assert!(volcengine_response_error(&ok_body).is_none());
|
||||
}
|
||||
|
||||
// ── 传输层错误通道语义:瞬时 → Err(前端 reject/retry),确定性 → Ok(success:false) ──
|
||||
//
|
||||
// 借 ZenMux 分支可指向任意 base_url 的特性,用本地 listener 驱动真实 HTTP
|
||||
// 路径,锁定 send 失败 / 读体中断 / 4xx / 非法 JSON 各自落在哪条通道。
|
||||
// balance / subscription 服务与本文件共用同一折叠模式,这里的用例同时充当
|
||||
// 三个服务的语义回归锚。
|
||||
|
||||
use super::get_coding_plan_quota;
|
||||
use crate::services::subscription::CredentialStatus;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// 测试进程内可能有其他用例临时 set_var HTTP_PROXY(http_client 的
|
||||
/// loopback 检测测试),NO_PROXY 保证本地回环请求始终直连。
|
||||
fn ensure_no_proxy_for_loopback() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
|
||||
std::env::set_var("no_proxy", "127.0.0.1,localhost");
|
||||
});
|
||||
}
|
||||
|
||||
/// 起一个只服务一次连接的本地 HTTP server。`response=None` 表示读完请求
|
||||
/// 直接断开(模拟响应前连接中断)。返回可命中 ZenMux 分支的 base_url。
|
||||
fn spawn_once_server(response: Option<String>) -> (String, std::thread::JoinHandle<()>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener");
|
||||
let port = listener.local_addr().expect("local addr").port();
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = stream.read(&mut buf);
|
||||
if let Some(resp) = response {
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
}
|
||||
});
|
||||
(format!("http://127.0.0.1:{port}/zenmux"), handle)
|
||||
}
|
||||
|
||||
fn http_response(status_line: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_connection_refused_returns_err() {
|
||||
ensure_no_proxy_for_loopback();
|
||||
// 绑定后立刻释放端口 → 连接被拒(send 失败)
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().expect("local addr").port();
|
||||
drop(listener);
|
||||
|
||||
let result =
|
||||
get_coding_plan_quota(&format!("http://127.0.0.1:{port}/zenmux"), "k", None, None)
|
||||
.await;
|
||||
let err = result.expect_err("send 失败必须走 Err 通道(瞬时,前端 reject 后重试)");
|
||||
assert!(err.contains("Network error"), "err={err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_connection_closed_before_response_returns_err() {
|
||||
ensure_no_proxy_for_loopback();
|
||||
let (base_url, handle) = spawn_once_server(None);
|
||||
|
||||
let result = get_coding_plan_quota(&base_url, "k", None, None).await;
|
||||
let err = result.expect_err("响应前连接中断必须走 Err 通道(瞬时)");
|
||||
assert!(err.contains("Network error"), "err={err}");
|
||||
handle.join().expect("server thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transient_truncated_body_returns_err() {
|
||||
ensure_no_proxy_for_loopback();
|
||||
// 声明 content-length: 100 但只写一小段就断开 → 读体中断。
|
||||
// 锁定 bytes() 先于解析:这类失败必须走 Err(瞬时),不能因 reqwest 把
|
||||
// 读体错误包成 decode 而被误判成确定性的 "Failed to parse response"。
|
||||
let (base_url, handle) = spawn_once_server(Some(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\npartial"
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
let result = get_coding_plan_quota(&base_url, "k", None, None).await;
|
||||
let err = result.expect_err("读体中断必须走 Err 通道(瞬时,前端 reject 后重试)");
|
||||
assert!(err.contains("Failed to read response"), "err={err}");
|
||||
handle.join().expect("server thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deterministic_http_401_stays_ok_with_auth_error() {
|
||||
ensure_no_proxy_for_loopback();
|
||||
let (base_url, handle) = spawn_once_server(Some(http_response("401 Unauthorized", "{}")));
|
||||
|
||||
let quota = get_coding_plan_quota(&base_url, "k", None, None)
|
||||
.await
|
||||
.expect("鉴权失败是确定性失败,必须保持 Ok(success:false) 展示文案");
|
||||
assert!(!quota.success);
|
||||
assert!(matches!(quota.credential_status, CredentialStatus::Expired));
|
||||
let err = quota.error.expect("应有错误文案");
|
||||
assert!(err.contains("Authentication failed (HTTP 401"), "err={err}");
|
||||
handle.join().expect("server thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deterministic_http_429_stays_ok_with_status_in_error() {
|
||||
ensure_no_proxy_for_loopback();
|
||||
let (base_url, handle) =
|
||||
spawn_once_server(Some(http_response("429 Too Many Requests", "slow down")));
|
||||
|
||||
let quota = get_coding_plan_quota(&base_url, "k", None, None)
|
||||
.await
|
||||
.expect("非 2xx 保持 Ok(success:false),状态码留在文案里交前端分类");
|
||||
assert!(!quota.success);
|
||||
// 前端 isTransientUsageError 靠 /http\s+(\d{3})/ 提取状态码把 429 归瞬时,
|
||||
// 文案格式是跨层契约,勿改。
|
||||
let err = quota.error.expect("应有错误文案");
|
||||
assert!(err.contains("HTTP 429"), "err={err}");
|
||||
handle.join().expect("server thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deterministic_invalid_json_body_stays_ok_with_parse_error() {
|
||||
ensure_no_proxy_for_loopback();
|
||||
// 完整读到响应体但不是 JSON → is_decode → 确定性解析失败
|
||||
let (base_url, handle) = spawn_once_server(Some(http_response("200 OK", "not-json")));
|
||||
|
||||
let quota = get_coding_plan_quota(&base_url, "k", None, None)
|
||||
.await
|
||||
.expect("完整但非法的响应体是确定性失败,必须保持 Ok(success:false)");
|
||||
assert!(!quota.success);
|
||||
let err = quota.error.expect("应有错误文案");
|
||||
assert!(err.contains("Failed to parse response"), "err={err}");
|
||||
handle.join().expect("server thread");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user