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
+7 -5
View File
@@ -930,6 +930,11 @@ fn run_task_worker(
}), }),
Err(error) => { Err(error) => {
let cancelled = cancel.load(Ordering::Relaxed); let cancelled = cancel.load(Ordering::Relaxed);
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
let code = error
.downcast_ref::<bat_infrastructure::DownloadError>()
.map(bat_infrastructure::DownloadError::code)
.unwrap_or(ErrorCode::INTERNAL);
registry.update(&job.id, |record| { registry.update(&job.id, |record| {
record.finished_at = Some(unix_seconds_now()); record.finished_at = Some(unix_seconds_now());
if cancelled { if cancelled {
@@ -941,11 +946,8 @@ fn run_task_worker(
)); ));
} else { } else {
record.status = "failed"; record.status = "failed";
record.error = Some(ApiError::new( record.error =
ErrorCode::INTERNAL, Some(ApiError::new(code, "task.executor", error.to_string()));
"task.executor",
error.to_string(),
));
} }
}); });
} }
+41
View File
@@ -229,6 +229,26 @@ impl CurlFailureKind {
} }
} }
/// 映射到统一错误码(网络域 3xx;代理故障 310001)。
pub(crate) fn error_code(&self) -> bat_core::ErrorCode {
use bat_core::ErrorCode;
match self {
Self::HttpForbidden => ErrorCode::HTTP_FORBIDDEN,
Self::HttpNotFound => ErrorCode::HTTP_NOT_FOUND,
Self::HttpClientError => ErrorCode::HTTP_CLIENT_ERROR,
Self::HttpTooManyRequests => ErrorCode::HTTP_TOO_MANY_REQUESTS,
Self::HttpServerError => ErrorCode::HTTP_SERVER_ERROR,
Self::Dns => ErrorCode::NETWORK_DNS,
Self::Connect => ErrorCode::NETWORK_CONNECT,
Self::Timeout => ErrorCode::NETWORK_TIMEOUT,
Self::Tls => ErrorCode::NETWORK_TLS,
Self::Interrupted => ErrorCode::NETWORK_INTERRUPTED,
Self::Proxy => ErrorCode::PROXY_FAILURE,
Self::HttpUnknown | Self::Network | Self::Unknown => ErrorCode::NETWORK_OTHER,
Self::ProcessFailed => ErrorCode::INTERNAL,
}
}
pub(crate) fn is_retryable(&self) -> bool { pub(crate) fn is_retryable(&self) -> bool {
match self { match self {
// 代理故障(认证失败、代理主机解析/连接失败)多为配置错误,per-URL 层 // 代理故障(认证失败、代理主机解析/连接失败)多为配置错误,per-URL 层
@@ -346,6 +366,11 @@ impl CurlRetryError {
self.final_failure().map(|failure| failure.kind.as_str()) self.final_failure().map(|failure| failure.kind.as_str())
} }
pub(crate) fn final_error_code(&self) -> Option<bat_core::ErrorCode> {
self.final_failure()
.map(|failure| failure.kind.error_code())
}
pub(crate) fn final_http_status(&self) -> Option<u16> { pub(crate) fn final_http_status(&self) -> Option<u16> {
self.final_failure().and_then(|failure| failure.http_status) self.final_failure().and_then(|failure| failure.http_status)
} }
@@ -547,6 +572,22 @@ mod tests {
assert!(!failure.retryable()); assert!(!failure.retryable());
} }
#[test]
fn failure_kind_maps_to_error_code() {
use bat_core::ErrorCode;
assert_eq!(
CurlFailureKind::HttpForbidden.error_code().id(),
ErrorCode::HTTP_FORBIDDEN.id()
);
assert_eq!(
CurlFailureKind::HttpNotFound.error_code().id(),
"BAT-ERR-300002"
);
assert_eq!(CurlFailureKind::Proxy.error_code().id(), "BAT-ERR-310001");
assert_eq!(CurlFailureKind::Timeout.error_code().id(), "BAT-ERR-300012");
assert_eq!(CurlFailureKind::Network.error_code().id(), "BAT-ERR-300015");
}
#[test] #[test]
fn parse_http_status_anchors_on_curl_error_context() { fn parse_http_status_anchors_on_curl_error_context() {
// 状态码前出现无关三位数字(如端口 443)时,仍从 "error" 之后取真正的状态码。 // 状态码前出现无关三位数字(如端口 443)时,仍从 "error" 之后取真正的状态码。
+1 -1
View File
@@ -32,7 +32,7 @@ pub use import::{
ResourceImportService, ResourceImportService,
}; };
pub use official_download::{ pub use official_download::{
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, DownloadError, OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem, OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourcePullService, OfficialResourcePullStatus,
+78 -20
View File
@@ -18,6 +18,44 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH}; 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_MANIFEST_FILE: &str = "official-download-manifest.json";
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json"; const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
const DOWNLOAD_MANIFEST_VERSION: u32 = 1; const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
@@ -495,7 +533,7 @@ impl OfficialResourcePullService {
pub fn pull( pub fn pull(
&self, &self,
plan: &OfficialResourcePullPlan, plan: &OfficialResourcePullPlan,
) -> Result<OfficialResourcePullReport, String> { ) -> Result<OfficialResourcePullReport, DownloadError> {
self.pull_with_progress(plan, |_| {}) self.pull_with_progress(plan, |_| {})
} }
@@ -505,7 +543,7 @@ impl OfficialResourcePullService {
&self, &self,
plan: &OfficialResourcePullPlan, plan: &OfficialResourcePullPlan,
mut progress: impl FnMut(OfficialResourcePullProgress), mut progress: impl FnMut(OfficialResourcePullProgress),
) -> Result<OfficialResourcePullReport, String> { ) -> Result<OfficialResourcePullReport, DownloadError> {
self.pull_with_progress_and_cancellation(plan, &mut progress, || false) self.pull_with_progress_and_cancellation(plan, &mut progress, || false)
} }
@@ -516,7 +554,7 @@ impl OfficialResourcePullService {
plan: &OfficialResourcePullPlan, plan: &OfficialResourcePullPlan,
mut progress: impl FnMut(OfficialResourcePullProgress), mut progress: impl FnMut(OfficialResourcePullProgress),
mut should_cancel: impl FnMut() -> bool, mut should_cancel: impl FnMut() -> bool,
) -> Result<OfficialResourcePullReport, String> { ) -> Result<OfficialResourcePullReport, DownloadError> {
self.ensure_output_root_ready()?; self.ensure_output_root_ready()?;
let official_hash_pairs = official_seed_hash_pairs(plan); let official_hash_pairs = official_seed_hash_pairs(plan);
@@ -530,7 +568,7 @@ impl OfficialResourcePullService {
let mut processed_urls = HashSet::<String>::new(); let mut processed_urls = HashSet::<String>::new();
for (offset, url) in urls.into_iter().enumerate() { for (offset, url) in urls.into_iter().enumerate() {
if should_cancel() { if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string()); return Err("官方资源拉取已被停止请求中断".to_string().into());
} }
let index = offset + 1; let index = offset + 1;
@@ -541,7 +579,10 @@ impl OfficialResourcePullService {
)); ));
if !is_official_yostar_jp_url(&url) { 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)?; let destination = self.destination_for_url(&url)?;
@@ -574,10 +615,13 @@ impl OfficialResourcePullService {
url.clone(), url.clone(),
&error, &error,
)); ));
return Err(format!( return Err(DownloadError::new(
error.error_code(),
format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={}{}", "官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={}{}",
self.download_quarantine_path().display(), self.download_quarantine_path().display(),
error.message error.message
),
)); ));
} }
}; };
@@ -613,7 +657,7 @@ impl OfficialResourcePullService {
} }
if should_cancel() { 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)?; 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) .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> { fn http_status(&self) -> Option<u16> {
self.retry_error self.retry_error
.as_ref() .as_ref()
@@ -2190,7 +2242,7 @@ exit 22
) )
.unwrap_err(); .unwrap_err();
assert!(error.contains("危险路径")); assert!(error.to_string().contains("危险路径"));
} }
#[test] #[test]
@@ -2251,7 +2303,7 @@ exit 22
let error = service let error = service
.pull(&build_official_pull_plan(discovery_plan(), inventory())) .pull(&build_official_pull_plan(discovery_plan(), inventory()))
.unwrap_err(); .unwrap_err();
assert!(error.contains("symlink")); assert!(error.to_string().contains("symlink"));
assert!(fs::read_dir(&escape_dir).unwrap().next().is_none()); assert!(fs::read_dir(&escape_dir).unwrap().next().is_none());
} }
@@ -2537,7 +2589,7 @@ exit 22
}; };
let error = service.pull(&plan).unwrap_err(); let error = service.pull(&plan).unwrap_err();
assert!(error.contains("官方 hash 校验失败")); assert!(error.to_string().contains("官方 hash 校验失败"));
let manifest = service.read_download_manifest().unwrap(); let manifest = service.read_download_manifest().unwrap();
assert!(manifest.entries.is_empty()); assert!(manifest.entries.is_empty());
@@ -2592,7 +2644,7 @@ exit 22
let error = service.pull(&plan).unwrap_err(); 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!(!partial_path_for(&service.destination_for_url(zip_url).unwrap()).exists());
assert!(service.read_download_manifest().unwrap().entries.is_empty()); assert!(service.read_download_manifest().unwrap().entries.is_empty());
} }
@@ -2801,9 +2853,11 @@ exit 22
.pull_with_progress(&plan, |event| events.push(event)) .pull_with_progress(&plan, |event| events.push(event))
.unwrap_err(); .unwrap_err();
assert!(error.contains("quarantine")); assert!(error.to_string().contains("quarantine"));
assert!(error.contains("http_not_found")); assert!(error.to_string().contains("http_not_found"));
assert!(error.contains("retryable=false")); assert!(error.to_string().contains("retryable=false"));
// 类型化错误携带准确的 HTTP 404 网络域码。
assert_eq!(error.code(), bat_core::ErrorCode::HTTP_NOT_FOUND);
assert_eq!( assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(), fs::read_to_string(curl_path.with_extension("state")).unwrap(),
"1" "1"
@@ -2849,8 +2903,10 @@ exit 22
let expected_retryable = fixture["retryable"].as_bool().unwrap(); let expected_retryable = fixture["retryable"].as_bool().unwrap();
let expected_attempts = fixture["expected_attempts"].as_u64().unwrap(); let expected_attempts = fixture["expected_attempts"].as_u64().unwrap();
assert!(error.contains(expected_kind)); assert!(error.to_string().contains(expected_kind));
assert!(error.contains(&format!("retryable={expected_retryable}"))); assert!(error
.to_string()
.contains(&format!("retryable={expected_retryable}")));
assert_eq!( assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(), fs::read_to_string(curl_path.with_extension("state")).unwrap(),
expected_attempts.to_string() expected_attempts.to_string()
@@ -2877,7 +2933,9 @@ exit 22
) )
.unwrap_err(); .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] #[test]
@@ -2895,9 +2953,9 @@ exit 22
let error = service.pull(&plan).unwrap_err(); let error = service.pull(&plan).unwrap_err();
assert!(error.contains("quarantine")); assert!(error.to_string().contains("quarantine"));
assert!(error.contains("http_server_error")); assert!(error.to_string().contains("http_server_error"));
assert!(error.contains("retryable=true")); assert!(error.to_string().contains("retryable=true"));
assert_eq!( assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(), fs::read_to_string(curl_path.with_extension("state")).unwrap(),
"3" "3"
+2 -1
View File
@@ -1339,7 +1339,8 @@ impl OfficialUpdateService {
}, },
&mut should_cancel, &mut should_cancel,
) )
.map_err(|error| anyhow::anyhow!(error)) // 用 Error::new 保留 DownloadError 类型(含错误码),供上层 downcast 归类。
.map_err(anyhow::Error::new)
.inspect_err(|error| { .inspect_err(|error| {
let _ = version_state_guard.fail(&error.to_string()); let _ = version_state_guard.fail(&error.to_string());
})?; })?;