fix(official-sync): 区分代理故障并前置校验代理配置

- 代理故障分类:curl 因代理 407 认证失败(CONNECT 隧道失败)、无法解析/连接
  代理等,原被归为普通可重试网络错误,永不成功也会耗尽 per-URL 重试且无诊断。
  新增 CurlFailureKind::Proxy,从 curl stderr 识别代理故障,per-URL 层 fail-fast
  不重试(daemon error-retry 循环仍整体重试以覆盖代理短暂抖动),并在失败摘要里
  以 kind=proxy 明确标注。
- 前置校验:parse_proxy_config 新增 validate_proxy_url,拒绝拼错的代理 scheme
  (如 htp://),只接受 http/https/socks4/socks4a/socks5/socks5h 或无 scheme
  的 host:port。
- doctor:proxy 检查不再恒 ok,对解析出的代理 URL(含 auto 从环境变量取得的)
  做 scheme 校验,前置暴露配置错误。

新增单测:代理 407/解析失败归为 Proxy 且不重试、源站连接失败仍为 Connect 可重试、
拒绝不支持的 scheme、无 scheme host:port 接受。

对应 issue #18 维护清单 2-5。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 08:18:37 -07:00
co-authored by Claude Fable 5
parent f0fbe65429
commit b570f37359
2 changed files with 120 additions and 5 deletions
+64 -2
View File
@@ -202,6 +202,8 @@ pub(crate) enum CurlFailureKind {
Tls,
Interrupted,
Network,
/// 代理自身故障:无法解析/连接代理,或代理返回 407 认证失败等。
Proxy,
ProcessFailed,
Unknown,
}
@@ -221,6 +223,7 @@ impl CurlFailureKind {
Self::Tls => "tls",
Self::Interrupted => "interrupted",
Self::Network => "network",
Self::Proxy => "proxy",
Self::ProcessFailed => "process_failed",
Self::Unknown => "unknown",
}
@@ -228,9 +231,13 @@ impl CurlFailureKind {
pub(crate) fn is_retryable(&self) -> bool {
match self {
// 代理故障(认证失败、代理主机解析/连接失败)多为配置错误,per-URL 层
// 不重试以免空转;daemon 的 error-retry 循环仍会在下一轮整体重试,覆盖
// 代理短暂抖动。
Self::HttpForbidden
| Self::HttpNotFound
| Self::HttpClientError
| Self::Proxy
| Self::ProcessFailed => false,
Self::HttpTooManyRequests
| Self::HttpServerError
@@ -261,7 +268,7 @@ impl CurlFailure {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let exit_code = output.status.code();
let http_status = parse_http_status(&stderr);
let kind = classify_failure(exit_code, http_status);
let kind = classify_failure(exit_code, http_status, &stderr);
Self {
url: url.to_string(),
destination: destination.map(Path::to_path_buf),
@@ -445,7 +452,29 @@ fn remove_proxy_env(command: &mut Command) {
}
}
fn classify_failure(exit_code: Option<i32>, http_status: Option<u16>) -> CurlFailureKind {
/// 从 curl stderr 判断是否为代理自身故障(而非源站故障)。
///
/// 覆盖:无法解析代理(curl exit 5)、CONNECT 隧道被代理拒绝或返回 4xx(如 407
/// 认证失败)等。curl 会把这些原因写进 stderr,比仅凭 exit code 更可靠地区分
/// 代理故障与源站故障。
fn is_proxy_failure(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("resolve proxy")
|| lower.contains("connect tunnel failed")
|| lower.contains("from proxy after connect")
|| (lower.contains("proxy") && lower.contains("407"))
}
fn classify_failure(
exit_code: Option<i32>,
http_status: Option<u16>,
stderr: &str,
) -> CurlFailureKind {
// 代理故障优先识别:这些错误来自代理链路而非官方源站,单独分类并 fail-fast。
if is_proxy_failure(stderr) {
return CurlFailureKind::Proxy;
}
if exit_code == Some(22) {
return match http_status {
Some(403) => CurlFailureKind::HttpForbidden,
@@ -527,6 +556,39 @@ mod tests {
assert!(failure.retryable());
}
#[test]
fn classifies_proxy_auth_and_resolution_failures() {
// 代理 407 认证失败:CONNECT 隧道失败,识别为 Proxy 且不重试。
let auth = Output {
status: std::process::ExitStatus::from_raw(56 << 8),
stdout: Vec::new(),
stderr: b"curl: (56) CONNECT tunnel failed, response 407".to_vec(),
};
let failure = CurlFailure::from_output("https://example.test/a", None, &auth);
assert_eq!(failure.kind, CurlFailureKind::Proxy);
assert!(!failure.retryable());
// 无法解析代理主机(curl exit 5):同样归为 Proxy 而非普通 DNS。
let resolve = Output {
status: std::process::ExitStatus::from_raw(5 << 8),
stdout: Vec::new(),
stderr: b"curl: (5) Could not resolve proxy: proxy.invalid".to_vec(),
};
let failure = CurlFailure::from_output("https://example.test/a", None, &resolve);
assert_eq!(failure.kind, CurlFailureKind::Proxy);
assert!(!failure.retryable());
// 源站普通连接失败(无代理标记)仍归为 Connect 且可重试。
let origin = Output {
status: std::process::ExitStatus::from_raw(7 << 8),
stdout: Vec::new(),
stderr: b"curl: (7) Failed to connect to example.test port 443".to_vec(),
};
let failure = CurlFailure::from_output("https://example.test/a", None, &origin);
assert_eq!(failure.kind, CurlFailureKind::Connect);
assert!(failure.retryable());
}
#[test]
fn auto_proxy_prefers_https_and_preserves_no_proxy() {
let pairs = vec![