diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 63d7a1e..0ed6cc0 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -930,6 +930,11 @@ fn run_task_worker( }), Err(error) => { let cancelled = cancel.load(Ordering::Relaxed); + // 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。 + let code = error + .downcast_ref::() + .map(bat_infrastructure::DownloadError::code) + .unwrap_or(ErrorCode::INTERNAL); registry.update(&job.id, |record| { record.finished_at = Some(unix_seconds_now()); if cancelled { @@ -941,11 +946,8 @@ fn run_task_worker( )); } else { record.status = "failed"; - record.error = Some(ApiError::new( - ErrorCode::INTERNAL, - "task.executor", - error.to_string(), - )); + record.error = + Some(ApiError::new(code, "task.executor", error.to_string())); } }); } diff --git a/infrastructure/src/curl_transfer.rs b/infrastructure/src/curl_transfer.rs index 40dc88c..318bb5f 100644 --- a/infrastructure/src/curl_transfer.rs +++ b/infrastructure/src/curl_transfer.rs @@ -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 { match self { // 代理故障(认证失败、代理主机解析/连接失败)多为配置错误,per-URL 层 @@ -346,6 +366,11 @@ impl CurlRetryError { self.final_failure().map(|failure| failure.kind.as_str()) } + pub(crate) fn final_error_code(&self) -> Option { + self.final_failure() + .map(|failure| failure.kind.error_code()) + } + pub(crate) fn final_http_status(&self) -> Option { self.final_failure().and_then(|failure| failure.http_status) } @@ -547,6 +572,22 @@ mod tests { 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] fn parse_http_status_anchors_on_curl_error_context() { // 状态码前出现无关三位数字(如端口 443)时,仍从 "error" 之后取真正的状态码。 diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 3962cb7..b0247f2 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -32,7 +32,7 @@ pub use import::{ ResourceImportService, }; pub use official_download::{ - OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, + DownloadError, OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem, OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService, OfficialResourcePullStatus, diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index c29f158..5dac1fe 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -18,6 +18,44 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。 +/// +/// 下载链路内部大量使用 `Result<_, String>`;这些经 `From` 归入 +/// `internal`,只有 curl 下载失败等有明确来源的错误会带上准确的网络域码。 +#[derive(Debug)] +pub struct DownloadError { + code: bat_core::ErrorCode, + message: String, +} + +impl DownloadError { + fn new(code: bat_core::ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + /// 该错误的统一错误码。 + pub fn code(&self) -> bat_core::ErrorCode { + self.code + } +} + +impl From 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 { + ) -> Result { self.pull_with_progress(plan, |_| {}) } @@ -505,7 +543,7 @@ impl OfficialResourcePullService { &self, plan: &OfficialResourcePullPlan, mut progress: impl FnMut(OfficialResourcePullProgress), - ) -> Result { + ) -> Result { 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 { + ) -> Result { 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::::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 { 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" diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 3c4fff3..989fab4 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -1339,7 +1339,8 @@ impl OfficialUpdateService { }, &mut should_cancel, ) - .map_err(|error| anyhow::anyhow!(error)) + // 用 Error::new 保留 DownloadError 类型(含错误码),供上层 downcast 归类。 + .map_err(anyhow::Error::new) .inspect_err(|error| { let _ = version_state_guard.fail(&error.to_string()); })?;