feat(official-sync): 下载失败携带类型化错误码

用类型化错误(不做 message 匹配)把资源下载失败归到准确的 BAT-ERR 码:

- curl_transfer:CurlFailureKind::error_code() 映射到网络域码(403→300001、
  404→300002、5xx→300005、dns/connect/timeout/tls/interrupted→30001x、
  proxy→310001、其余→300015);CurlRetryError::final_error_code() 取末次失败的码。
- official_download:新增 pub DownloadError { code, message },From<String>→internal,
  Display/Error;pull_* 的返回类型由 String 改为 DownloadError,内部 String 错误经
  From 归 internal,下载失败边界带 pull_one 的 error_code(),非官方 URL 带
  non_official_url(300030)。
- official_update:pull 错误改用 anyhow::Error::new 保留 DownloadError 类型。
- 任务 worker:downcast DownloadError 取 code 写入任务记录的 error(失败不再一律
  900001),非下载失败仍归 internal。

验证:新增 kind→code 映射单测;pull 404 测试断言 DownloadError.code()==HTTP_NOT_FOUND;
lib/core 全量测试通过,clippy --all-targets 干净。

范围说明:本次覆盖资源下载路径;launcher/metadata 拉取(official_launcher)的网络
失败仍归 internal,可按同一模式后续接入。

对应 issue #1(错误码细分映射,第一批)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 07:01:31 -07:00
co-authored by Claude Fable 5
parent 9a5fef23af
commit ab11cc899d
5 changed files with 132 additions and 30 deletions
+81 -23
View File
@@ -18,6 +18,44 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
///
/// 下载链路内部大量使用 `Result<_, String>`;这些经 `From<String>` 归入
/// `internal`,只有 curl 下载失败等有明确来源的错误会带上准确的网络域码。
#[derive(Debug)]
pub struct DownloadError {
code: bat_core::ErrorCode,
message: String,
}
impl DownloadError {
fn new(code: bat_core::ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// 该错误的统一错误码。
pub fn code(&self) -> bat_core::ErrorCode {
self.code
}
}
impl From<String> for DownloadError {
fn from(message: String) -> Self {
Self::new(bat_core::ErrorCode::INTERNAL, message)
}
}
impl std::fmt::Display for DownloadError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for DownloadError {}
const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
@@ -495,7 +533,7 @@ impl OfficialResourcePullService {
pub fn pull(
&self,
plan: &OfficialResourcePullPlan,
) -> Result<OfficialResourcePullReport, String> {
) -> Result<OfficialResourcePullReport, DownloadError> {
self.pull_with_progress(plan, |_| {})
}
@@ -505,7 +543,7 @@ impl OfficialResourcePullService {
&self,
plan: &OfficialResourcePullPlan,
mut progress: impl FnMut(OfficialResourcePullProgress),
) -> Result<OfficialResourcePullReport, String> {
) -> Result<OfficialResourcePullReport, DownloadError> {
self.pull_with_progress_and_cancellation(plan, &mut progress, || false)
}
@@ -516,7 +554,7 @@ impl OfficialResourcePullService {
plan: &OfficialResourcePullPlan,
mut progress: impl FnMut(OfficialResourcePullProgress),
mut should_cancel: impl FnMut() -> bool,
) -> Result<OfficialResourcePullReport, String> {
) -> Result<OfficialResourcePullReport, DownloadError> {
self.ensure_output_root_ready()?;
let official_hash_pairs = official_seed_hash_pairs(plan);
@@ -530,7 +568,7 @@ impl OfficialResourcePullService {
let mut processed_urls = HashSet::<String>::new();
for (offset, url) in urls.into_iter().enumerate() {
if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string());
return Err("官方资源拉取已被停止请求中断".to_string().into());
}
let index = offset + 1;
@@ -541,7 +579,10 @@ impl OfficialResourcePullService {
));
if !is_official_yostar_jp_url(&url) {
return Err(format!("拒绝下载非官方 URL{url}"));
return Err(DownloadError::new(
bat_core::ErrorCode::NON_OFFICIAL_URL,
format!("拒绝下载非官方 URL{url}"),
));
}
let destination = self.destination_for_url(&url)?;
@@ -574,10 +615,13 @@ impl OfficialResourcePullService {
url.clone(),
&error,
));
return Err(format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={}{}",
self.download_quarantine_path().display(),
error.message
return Err(DownloadError::new(
error.error_code(),
format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={}{}",
self.download_quarantine_path().display(),
error.message
),
));
}
};
@@ -613,7 +657,7 @@ impl OfficialResourcePullService {
}
if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string());
return Err("官方资源拉取已被停止请求中断".to_string().into());
}
self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?;
@@ -1552,6 +1596,14 @@ impl PullOneError {
.and_then(CurlRetryError::final_kind_label)
}
/// 映射到统一错误码:curl 重试失败取网络域码,其余(进程/续传/替换等)归 INTERNAL。
fn error_code(&self) -> bat_core::ErrorCode {
self.retry_error
.as_ref()
.and_then(CurlRetryError::final_error_code)
.unwrap_or(bat_core::ErrorCode::INTERNAL)
}
fn http_status(&self) -> Option<u16> {
self.retry_error
.as_ref()
@@ -2190,7 +2242,7 @@ exit 22
)
.unwrap_err();
assert!(error.contains("危险路径"));
assert!(error.to_string().contains("危险路径"));
}
#[test]
@@ -2251,7 +2303,7 @@ exit 22
let error = service
.pull(&build_official_pull_plan(discovery_plan(), inventory()))
.unwrap_err();
assert!(error.contains("symlink"));
assert!(error.to_string().contains("symlink"));
assert!(fs::read_dir(&escape_dir).unwrap().next().is_none());
}
@@ -2537,7 +2589,7 @@ exit 22
};
let error = service.pull(&plan).unwrap_err();
assert!(error.contains("官方 hash 校验失败"));
assert!(error.to_string().contains("官方 hash 校验失败"));
let manifest = service.read_download_manifest().unwrap();
assert!(manifest.entries.is_empty());
@@ -2592,7 +2644,7 @@ exit 22
let error = service.pull(&plan).unwrap_err();
assert!(error.contains("ZIP"));
assert!(error.to_string().contains("ZIP"));
assert!(!partial_path_for(&service.destination_for_url(zip_url).unwrap()).exists());
assert!(service.read_download_manifest().unwrap().entries.is_empty());
}
@@ -2801,9 +2853,11 @@ exit 22
.pull_with_progress(&plan, |event| events.push(event))
.unwrap_err();
assert!(error.contains("quarantine"));
assert!(error.contains("http_not_found"));
assert!(error.contains("retryable=false"));
assert!(error.to_string().contains("quarantine"));
assert!(error.to_string().contains("http_not_found"));
assert!(error.to_string().contains("retryable=false"));
// 类型化错误携带准确的 HTTP 404 网络域码。
assert_eq!(error.code(), bat_core::ErrorCode::HTTP_NOT_FOUND);
assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
"1"
@@ -2849,8 +2903,10 @@ exit 22
let expected_retryable = fixture["retryable"].as_bool().unwrap();
let expected_attempts = fixture["expected_attempts"].as_u64().unwrap();
assert!(error.contains(expected_kind));
assert!(error.contains(&format!("retryable={expected_retryable}")));
assert!(error.to_string().contains(expected_kind));
assert!(error
.to_string()
.contains(&format!("retryable={expected_retryable}")));
assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
expected_attempts.to_string()
@@ -2877,7 +2933,9 @@ exit 22
)
.unwrap_err();
assert!(error.contains(fixture["expected_error_contains"].as_str().unwrap()));
assert!(error
.to_string()
.contains(fixture["expected_error_contains"].as_str().unwrap()));
}
#[test]
@@ -2895,9 +2953,9 @@ exit 22
let error = service.pull(&plan).unwrap_err();
assert!(error.contains("quarantine"));
assert!(error.contains("http_server_error"));
assert!(error.contains("retryable=true"));
assert!(error.to_string().contains("quarantine"));
assert!(error.to_string().contains("http_server_error"));
assert!(error.to_string().contains("retryable=true"));
assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
"3"