mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:15:14 +08:00
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:
@@ -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(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" 之后取真正的状态码。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={};{}",
|
error.error_code(),
|
||||||
self.download_quarantine_path().display(),
|
format!(
|
||||||
error.message
|
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={};{}",
|
||||||
|
self.download_quarantine_path().display(),
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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());
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
Reference in New Issue
Block a user