fix: classify official download failures

Distinguish terminal HTTP errors from retryable CDN/network failures, record quarantined resources, and report failed download progress.

Fixes #7
This commit is contained in:
2026-07-14 01:30:12 +08:00
parent 25c2c4d40f
commit 4192d7ee4c
15 changed files with 1034 additions and 195 deletions
+453 -105
View File
@@ -1,5 +1,6 @@
//! Official JP resource download execution.
use crate::curl_transfer::{run_curl_with_retry, CurlRetryError};
use crate::official_pull::OfficialResourcePullPlan;
use crate::path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target,
@@ -15,9 +16,12 @@ use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
const DEFAULT_RETRY_ATTEMPTS: usize = 3;
/// Outcome for one official resource pull item.
@@ -49,6 +53,8 @@ pub enum OfficialResourcePullProgressKind {
Started,
/// A URL finished as skipped, resumed, or downloaded.
Finished,
/// A URL failed after retry classification and was quarantined for this run.
Failed,
}
impl OfficialResourcePullProgressKind {
@@ -57,6 +63,7 @@ impl OfficialResourcePullProgressKind {
match self {
Self::Started => "started",
Self::Finished => "finished",
Self::Failed => "failed",
}
}
}
@@ -78,6 +85,16 @@ pub struct OfficialResourcePullProgress {
pub bytes: Option<u64>,
/// Bytes transferred during this run, when `kind` is `Finished`.
pub transferred_bytes: Option<u64>,
/// Stable failure kind label, when `kind` is `Failed`.
pub failure_kind: Option<String>,
/// HTTP status parsed from curl stderr, when available.
pub failure_http_status: Option<u16>,
/// Whether retry policy considered the final failure retryable.
pub failure_retryable: Option<bool>,
/// Number of curl attempts that were executed before this failure.
pub failure_attempts: Option<usize>,
/// Whether the URL was written to the local quarantine manifest.
pub quarantined: bool,
}
impl OfficialResourcePullProgress {
@@ -90,6 +107,11 @@ impl OfficialResourcePullProgress {
status: None,
bytes: None,
transferred_bytes: None,
failure_kind: None,
failure_http_status: None,
failure_retryable: None,
failure_attempts: None,
quarantined: false,
}
}
@@ -109,6 +131,28 @@ impl OfficialResourcePullProgress {
status: Some(status),
bytes: Some(bytes),
transferred_bytes: Some(transferred_bytes),
failure_kind: None,
failure_http_status: None,
failure_retryable: None,
failure_attempts: None,
quarantined: false,
}
}
fn failed(index: usize, total: usize, url: String, error: &PullOneError) -> Self {
Self {
kind: OfficialResourcePullProgressKind::Failed,
index,
total,
url,
status: None,
bytes: None,
transferred_bytes: None,
failure_kind: error.failure_kind().map(ToOwned::to_owned),
failure_http_status: error.http_status(),
failure_retryable: error.retryable(),
failure_attempts: error.attempts(),
quarantined: true,
}
}
}
@@ -434,6 +478,11 @@ impl OfficialResourcePullService {
self.output_root.join(DOWNLOAD_MANIFEST_FILE)
}
/// Returns the local quarantine path used to diagnose failed downloads.
pub fn download_quarantine_path(&self) -> PathBuf {
self.output_root.join(DOWNLOAD_QUARANTINE_FILE)
}
/// Executes the plan and downloads every official URL to disk.
pub fn pull(
&self,
@@ -504,9 +553,27 @@ impl OfficialResourcePullService {
self.validated_existing_file(&url, &destination, &manifest)?
};
let result = if let Some(result) = result {
self.clear_quarantine_entry(&url)?;
result
} else {
let result = self.pull_one(&url, &destination)?;
let result = match self.pull_one(&url, &destination) {
Ok(result) => result,
Err(error) => {
self.record_quarantine_entry(&url, &destination, &error)?;
progress(OfficialResourcePullProgress::failed(
index,
total,
url.clone(),
&error,
));
return Err(format!(
"官方资源下载中断:URL 已进入 quarantine 并跳过本轮,不会发布不完整资源;url={url} quarantine={}{}",
self.download_quarantine_path().display(),
error.message
));
}
};
self.clear_quarantine_entry(&url)?;
self.record_download_manifest_entry(&mut manifest, &url, &destination)?;
self.write_download_manifest(&manifest)?;
result
@@ -642,49 +709,32 @@ impl OfficialResourcePullService {
return Err(format!("拒绝拉取非官方 URL{url}"));
}
let attempts = self.retry_attempts.max(1);
let mut last_error = None;
for attempt in 1..=attempts {
match self.fetch_bytes_once(url) {
Ok(bytes) => return Ok(bytes),
Err(error) => {
last_error = Some(format!("{attempt}/{attempts} 次尝试失败:{error}"));
}
}
}
Err(last_error.unwrap_or_else(|| format!("curl 未执行就已失败:{url}")))
}
fn fetch_bytes_once(&self, url: &str) -> Result<Vec<u8>, String> {
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(url)
.output()
.map_err(|error| {
format!(
"启动 curl 命令失败 {}{error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl 拉取失败 {url}{}", stderr.trim()));
}
let output =
run_curl_with_retry(&self.curl_command, url, None, self.retry_attempts, || {
let mut command = Command::new(&self.curl_command);
command
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(url);
command
})
.map_err(|error| format!("curl 拉取失败 {url}{error}"))?;
Ok(output.stdout)
}
fn pull_one(&self, url: &str, destination: &Path) -> Result<PullOneResult, String> {
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")?;
fn pull_one(&self, url: &str, destination: &Path) -> Result<PullOneResult, PullOneError> {
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")
.map_err(PullOneError::plain)?;
let partial = partial_path_for(destination);
ensure_safe_file_target(&self.output_root, &partial, "临时下载文件")?;
let partial_len_before = file_len_if_exists(&partial)?.unwrap_or_default();
ensure_safe_file_target(&self.output_root, &partial, "临时下载文件")
.map_err(PullOneError::plain)?;
let partial_len_before = file_len_if_exists(&partial)
.map_err(PullOneError::plain)?
.unwrap_or_default();
let resumed = partial_len_before > 0;
let mut status = if resumed {
OfficialResourcePullStatus::Resumed
@@ -697,43 +747,60 @@ impl OfficialResourcePullService {
let _ = fs::remove_file(&partial);
self.download_one(url, &partial, false)
.map_err(|retry_error| {
format!("续传失败 {url}{error};清理后重新下载也失败:{retry_error}")
PullOneError::from_retry(
format!("续传失败 {url}{error};清理后重新下载也失败:{retry_error}"),
retry_error,
)
})?;
status = OfficialResourcePullStatus::Downloaded;
} else if let Err(error) = self.validate_zip_if_needed(url, &partial) {
let _ = fs::remove_file(&partial);
self.download_one(url, &partial, false)
.map_err(|retry_error| {
format!("续传后的 ZIP 结构校验失败 {url}{error};清理后重新下载也失败:{retry_error}")
PullOneError::from_retry(
format!("续传后的 ZIP 结构校验失败 {url}{error};清理后重新下载也失败:{retry_error}"),
retry_error,
)
})?;
status = OfficialResourcePullStatus::Downloaded;
}
} else {
self.download_one(url, &partial, false)?;
self.download_one(url, &partial, false).map_err(|error| {
PullOneError::from_retry(format!("下载失败 {url}{error}"), error)
})?;
}
if let Err(error) = self.validate_zip_if_needed(url, &partial) {
let _ = fs::remove_file(&partial);
return Err(error);
return Err(PullOneError::plain(error));
}
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")?;
if file_len_if_exists(destination)?.is_some() {
fs::remove_file(destination).map_err(|error| {
format!("替换已有目标文件失败 {}{error}", destination.display())
})?;
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")
.map_err(PullOneError::plain)?;
if file_len_if_exists(destination)
.map_err(PullOneError::plain)?
.is_some()
{
fs::remove_file(destination)
.map_err(|error| format!("替换已有目标文件失败 {}{error}", destination.display()))
.map_err(PullOneError::plain)?;
}
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")?;
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")
.map_err(PullOneError::plain)?;
fs::rename(&partial, destination).map_err(|error| {
format!(
"移动临时下载文件失败 {} -> {}{error}",
partial.display(),
destination.display()
)
})?;
fs::rename(&partial, destination)
.map_err(|error| {
format!(
"移动临时下载文件失败 {} -> {}{error}",
partial.display(),
destination.display()
)
})
.map_err(PullOneError::plain)?;
let bytes = file_len_if_exists(destination)?
.ok_or_else(|| format!("下载完成后目标文件缺失 {}", destination.display()))?;
let bytes = file_len_if_exists(destination)
.map_err(PullOneError::plain)?
.ok_or_else(|| format!("下载完成后目标文件缺失 {}", destination.display()))
.map_err(PullOneError::plain)?;
Ok(PullOneResult {
bytes,
@@ -746,53 +813,34 @@ impl OfficialResourcePullService {
})
}
fn download_one(&self, url: &str, destination: &Path, resume: bool) -> Result<(), String> {
let attempts = self.retry_attempts.max(1);
let mut last_error = None;
for attempt in 1..=attempts {
match self.download_one_once(url, destination, resume) {
Ok(()) => return Ok(()),
Err(error) => {
last_error = Some(format!("{attempt}/{attempts} 次尝试失败:{error}"));
fn download_one(
&self,
url: &str,
destination: &Path,
resume: bool,
) -> Result<(), CurlRetryError> {
run_curl_with_retry(
&self.curl_command,
url,
Some(destination),
self.retry_attempts,
|| {
let mut command = Command::new(&self.curl_command);
command
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--output")
.arg(destination);
if resume {
command.arg("--continue-at").arg("-");
}
}
}
Err(last_error
.unwrap_or_else(|| format!("curl 未执行就已失败:{url} -> {}", destination.display())))
}
fn download_one_once(&self, url: &str, destination: &Path, resume: bool) -> Result<(), String> {
let mut command = Command::new(&self.curl_command);
command
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--output")
.arg(destination);
if resume {
command.arg("--continue-at").arg("-");
}
command.arg("--url").arg(url);
let output = command.output().map_err(|error| {
format!(
"启动 curl 命令失败 {}{error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl 下载失败 {url} -> {}{}",
destination.display(),
stderr.trim()
));
}
Ok(())
command.arg("--url").arg(url);
command
},
)
.map(|_| ())
}
fn verify_ready_official_hashes(
@@ -935,6 +983,82 @@ impl OfficialResourcePullService {
Ok(())
}
fn read_download_quarantine(&self) -> Result<OfficialDownloadQuarantineManifest, String> {
self.ensure_output_root_safe()?;
let path = self.download_quarantine_path();
ensure_safe_file_target(&self.output_root, &path, "下载 quarantine")?;
let Some(bytes) = read_file_no_symlink(&path, "下载 quarantine")? else {
return Ok(OfficialDownloadQuarantineManifest::default());
};
let manifest: OfficialDownloadQuarantineManifest = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析下载 quarantine 失败 {}{error}", path.display()))?;
if manifest.version != DOWNLOAD_QUARANTINE_VERSION {
return Err(format!(
"不支持的下载 quarantine 版本 {},文件 {}",
manifest.version,
path.display()
));
}
Ok(manifest)
}
fn write_download_quarantine(
&self,
manifest: &OfficialDownloadQuarantineManifest,
) -> Result<(), String> {
self.ensure_output_root_ready()?;
let path = self.download_quarantine_path();
if let Some(parent) = path.parent() {
ensure_safe_directory_path(parent, "下载 quarantine 目录")?;
fs::create_dir_all(parent).map_err(|error| {
format!("创建下载 quarantine 目录失败 {}{error}", parent.display())
})?;
ensure_safe_directory_path(parent, "下载 quarantine 目录")?;
}
ensure_safe_file_target(&self.output_root, &path, "下载 quarantine")?;
let bytes = serde_json::to_vec_pretty(manifest)
.map_err(|error| format!("序列化下载 quarantine 失败 {}{error}", path.display()))?;
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "下载 quarantine")?;
Ok(())
}
fn record_quarantine_entry(
&self,
url: &str,
destination: &Path,
error: &PullOneError,
) -> Result<(), String> {
let mut manifest = self.read_download_quarantine()?;
manifest.entries.insert(
url.to_string(),
OfficialDownloadQuarantineEntry {
url: url.to_string(),
destination: self.relative_destination(destination)?,
failed_at_unix_seconds: unix_seconds_now(),
attempts: error.attempts().unwrap_or(0),
failure_kind: error.failure_kind().map(ToOwned::to_owned),
http_status: error.http_status(),
retryable: error.retryable(),
skipped_this_run: true,
last_error: error.message.clone(),
},
);
self.write_download_quarantine(&manifest)
}
fn clear_quarantine_entry(&self, url: &str) -> Result<(), String> {
let mut manifest = self.read_download_quarantine()?;
if manifest.entries.remove(url).is_none() {
return Ok(());
}
self.write_download_quarantine(&manifest)
}
fn validated_existing_file(
&self,
url: &str,
@@ -1341,10 +1465,44 @@ struct OfficialDownloadManifestEntry {
blake3: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct OfficialDownloadQuarantineManifest {
#[serde(default = "default_download_quarantine_version")]
version: u32,
#[serde(default)]
entries: BTreeMap<String, OfficialDownloadQuarantineEntry>,
}
impl Default for OfficialDownloadQuarantineManifest {
fn default() -> Self {
Self {
version: DOWNLOAD_QUARANTINE_VERSION,
entries: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct OfficialDownloadQuarantineEntry {
url: String,
destination: String,
failed_at_unix_seconds: u64,
attempts: usize,
failure_kind: Option<String>,
http_status: Option<u16>,
retryable: Option<bool>,
skipped_this_run: bool,
last_error: String,
}
fn default_download_manifest_version() -> u32 {
DOWNLOAD_MANIFEST_VERSION
}
fn default_download_quarantine_version() -> u32 {
DOWNLOAD_QUARANTINE_VERSION
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PullOneResult {
bytes: u64,
@@ -1352,6 +1510,58 @@ struct PullOneResult {
status: OfficialResourcePullStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PullOneError {
message: String,
retry_error: Option<CurlRetryError>,
}
impl PullOneError {
fn plain(message: String) -> Self {
Self {
message,
retry_error: None,
}
}
fn from_retry(message: String, retry_error: CurlRetryError) -> Self {
Self {
message,
retry_error: Some(retry_error),
}
}
fn failure_kind(&self) -> Option<&'static str> {
self.retry_error
.as_ref()
.and_then(CurlRetryError::final_kind_label)
}
fn http_status(&self) -> Option<u16> {
self.retry_error
.as_ref()
.and_then(CurlRetryError::final_http_status)
}
fn retryable(&self) -> Option<bool> {
self.retry_error
.as_ref()
.and_then(CurlRetryError::final_retryable)
}
fn attempts(&self) -> Option<usize> {
self.retry_error.as_ref().map(CurlRetryError::attempt_count)
}
}
impl std::fmt::Display for PullOneError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for PullOneError {}
fn file_len_if_exists(path: &Path) -> Result<Option<u64>, String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
@@ -1370,6 +1580,13 @@ fn partial_path_for(destination: &Path) -> PathBuf {
PathBuf::from(partial)
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn blake3_file_hex(path: &Path) -> Result<String, String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
@@ -1769,6 +1986,35 @@ fi
"#
}
fn fake_http_error_curl_script(status: u16) -> String {
format!(
r#"#!/bin/sh
set -eu
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
--url)
url="$2"
shift 2
;;
*)
shift
;;
esac
done
state="$0.state"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
printf '%s' "$count" > "$state"
printf 'curl: (22) The requested URL returned error: {status}\n' >&2
exit 22
"#
)
}
fn write_shell_script(path: &Path, script: &str) {
fs::write(path, script).unwrap();
#[cfg(unix)]
@@ -1800,6 +2046,32 @@ fi
write_shell_script(path, fake_bad_zip_curl_script());
}
fn write_fake_http_error_curl(path: &Path, status: u16) {
write_shell_script(path, &fake_http_error_curl_script(status));
}
fn one_url_plan(url: &str) -> OfficialResourcePullPlan {
OfficialResourcePullPlan {
discovery: YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: None,
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token"
.to_string(),
endpoints: vec![YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: url.to_string(),
}],
},
inventory: YostarJpPlatformDownloadInventory::from_shared_inventory(
YostarJpDownloadInventory::from_catalog_bytes(b"", b"", b""),
&[],
),
platforms: Vec::new(),
}
}
fn one_file_zip(name: &[u8], data: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
@@ -1965,7 +2237,7 @@ fi
symlink(&escape_file, partial_path_for(&destination)).unwrap();
let error = service.pull_one(url, &destination).unwrap_err();
assert!(error.contains("symlink"));
assert!(error.to_string().contains("symlink"));
assert_eq!(fs::read(&escape_file).unwrap(), b"outside");
}
@@ -2470,6 +2742,82 @@ fi
);
}
#[test]
fn does_not_retry_terminal_http_404_and_records_quarantine() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_http_error_curl(&curl_path, 404);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
.with_retry_attempts(3);
let url =
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Missing.bytes";
let plan = one_url_plan(url);
let mut events = Vec::new();
let error = service
.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_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
"1"
);
let failed = events
.iter()
.find(|event| event.kind == OfficialResourcePullProgressKind::Failed)
.unwrap();
assert_eq!(failed.failure_kind.as_deref(), Some("http_not_found"));
assert_eq!(failed.failure_http_status, Some(404));
assert_eq!(failed.failure_retryable, Some(false));
assert_eq!(failed.failure_attempts, Some(1));
assert!(failed.quarantined);
let quarantine = service.read_download_quarantine().unwrap();
let entry = quarantine.entries.get(url).unwrap();
assert_eq!(entry.http_status, Some(404));
assert_eq!(entry.failure_kind.as_deref(), Some("http_not_found"));
assert_eq!(entry.retryable, Some(false));
assert_eq!(entry.attempts, 1);
assert!(entry.skipped_this_run);
assert!(service.read_download_manifest().unwrap().entries.is_empty());
}
#[test]
fn retries_http_5xx_before_quarantine() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_http_error_curl(&curl_path, 503);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
.with_retry_attempts(3);
let url =
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Busy.bytes";
let plan = one_url_plan(url);
let error = service.pull(&plan).unwrap_err();
assert!(error.contains("quarantine"));
assert!(error.contains("http_server_error"));
assert!(error.contains("retryable=true"));
assert_eq!(
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
"3"
);
let quarantine = service.read_download_quarantine().unwrap();
let entry = quarantine.entries.get(url).unwrap();
assert_eq!(entry.http_status, Some(503));
assert_eq!(entry.failure_kind.as_deref(), Some("http_server_error"));
assert_eq!(entry.retryable, Some(true));
assert_eq!(entry.attempts, 3);
assert!(entry.last_error.contains("重试次数已耗尽"));
}
#[test]
fn custom_platform_selection_is_supported() {
let out_dir = TempDir::new().unwrap();