mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 05:25:14 +08:00
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:
@@ -0,0 +1,308 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CurlFailureKind {
|
||||
HttpForbidden,
|
||||
HttpNotFound,
|
||||
HttpClientError,
|
||||
HttpTooManyRequests,
|
||||
HttpServerError,
|
||||
HttpUnknown,
|
||||
Dns,
|
||||
Connect,
|
||||
Timeout,
|
||||
Tls,
|
||||
Interrupted,
|
||||
Network,
|
||||
ProcessFailed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl CurlFailureKind {
|
||||
pub(crate) fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::HttpForbidden => "http_forbidden",
|
||||
Self::HttpNotFound => "http_not_found",
|
||||
Self::HttpClientError => "http_client_error",
|
||||
Self::HttpTooManyRequests => "http_too_many_requests",
|
||||
Self::HttpServerError => "http_server_error",
|
||||
Self::HttpUnknown => "http_unknown",
|
||||
Self::Dns => "dns",
|
||||
Self::Connect => "connect",
|
||||
Self::Timeout => "timeout",
|
||||
Self::Tls => "tls",
|
||||
Self::Interrupted => "interrupted",
|
||||
Self::Network => "network",
|
||||
Self::ProcessFailed => "process_failed",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
Self::HttpForbidden
|
||||
| Self::HttpNotFound
|
||||
| Self::HttpClientError
|
||||
| Self::ProcessFailed => false,
|
||||
Self::HttpTooManyRequests
|
||||
| Self::HttpServerError
|
||||
| Self::HttpUnknown
|
||||
| Self::Dns
|
||||
| Self::Connect
|
||||
| Self::Timeout
|
||||
| Self::Tls
|
||||
| Self::Interrupted
|
||||
| Self::Network
|
||||
| Self::Unknown => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CurlFailure {
|
||||
pub(crate) url: String,
|
||||
pub(crate) destination: Option<PathBuf>,
|
||||
pub(crate) exit_code: Option<i32>,
|
||||
pub(crate) stderr: String,
|
||||
pub(crate) http_status: Option<u16>,
|
||||
pub(crate) kind: CurlFailureKind,
|
||||
}
|
||||
|
||||
impl CurlFailure {
|
||||
fn from_output(url: &str, destination: Option<&Path>, output: &Output) -> Self {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let exit_code = output.status.code();
|
||||
let http_status = parse_http_status(&stderr);
|
||||
let kind = classify_failure(exit_code, http_status);
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
destination: destination.map(Path::to_path_buf),
|
||||
exit_code,
|
||||
stderr,
|
||||
http_status,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn process_failed(
|
||||
url: &str,
|
||||
destination: Option<&Path>,
|
||||
curl_command: &Path,
|
||||
error: std::io::Error,
|
||||
) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
destination: destination.map(Path::to_path_buf),
|
||||
exit_code: None,
|
||||
stderr: format!("启动 curl 命令失败 {}:{error}", curl_command.display()),
|
||||
http_status: None,
|
||||
kind: CurlFailureKind::ProcessFailed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retryable(&self) -> bool {
|
||||
self.kind.is_retryable()
|
||||
}
|
||||
|
||||
fn format_compact(&self) -> String {
|
||||
let mut parts = vec![
|
||||
format!("kind={}", self.kind.as_str()),
|
||||
format!("retryable={}", self.retryable()),
|
||||
format!("url={}", self.url),
|
||||
];
|
||||
if let Some(status) = self.http_status {
|
||||
parts.push(format!("http_status={status}"));
|
||||
}
|
||||
if let Some(code) = self.exit_code {
|
||||
parts.push(format!("curl_exit={code}"));
|
||||
}
|
||||
if let Some(destination) = self.destination.as_ref() {
|
||||
parts.push(format!("destination={}", destination.display()));
|
||||
}
|
||||
if !self.stderr.is_empty() {
|
||||
parts.push(format!("stderr={}", self.stderr));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CurlAttemptFailure {
|
||||
pub(crate) attempt: usize,
|
||||
pub(crate) attempts: usize,
|
||||
pub(crate) failure: CurlFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CurlRetryError {
|
||||
pub(crate) attempts: Vec<CurlAttemptFailure>,
|
||||
}
|
||||
|
||||
impl CurlRetryError {
|
||||
pub(crate) fn final_failure(&self) -> Option<&CurlFailure> {
|
||||
self.attempts.last().map(|attempt| &attempt.failure)
|
||||
}
|
||||
|
||||
pub(crate) fn attempt_count(&self) -> usize {
|
||||
self.attempts.len()
|
||||
}
|
||||
|
||||
pub(crate) fn final_kind_label(&self) -> Option<&'static str> {
|
||||
self.final_failure().map(|failure| failure.kind.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn final_http_status(&self) -> Option<u16> {
|
||||
self.final_failure().and_then(|failure| failure.http_status)
|
||||
}
|
||||
|
||||
pub(crate) fn final_retryable(&self) -> Option<bool> {
|
||||
self.final_failure().map(CurlFailure::retryable)
|
||||
}
|
||||
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
let Some(final_failure) = self.final_failure() else {
|
||||
return "curl 未执行就已失败".to_string();
|
||||
};
|
||||
let policy = if final_failure.retryable() {
|
||||
"重试次数已耗尽"
|
||||
} else {
|
||||
"不可重试,已提前停止"
|
||||
};
|
||||
let attempts = self
|
||||
.attempts
|
||||
.iter()
|
||||
.map(|attempt| {
|
||||
format!(
|
||||
"第 {}/{} 次尝试失败:{}",
|
||||
attempt.attempt,
|
||||
attempt.attempts,
|
||||
attempt.failure.format_compact()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
format!("{policy};{attempts}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CurlRetryError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.summary())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CurlRetryError {}
|
||||
|
||||
pub(crate) fn run_curl_with_retry(
|
||||
curl_command: &Path,
|
||||
url: &str,
|
||||
destination: Option<&Path>,
|
||||
attempts: usize,
|
||||
mut build_command: impl FnMut() -> Command,
|
||||
) -> Result<Output, CurlRetryError> {
|
||||
let attempts = attempts.max(1);
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for attempt in 1..=attempts {
|
||||
let failure = match build_command().output() {
|
||||
Ok(output) if output.status.success() => return Ok(output),
|
||||
Ok(output) => CurlFailure::from_output(url, destination, &output),
|
||||
Err(error) => CurlFailure::process_failed(url, destination, curl_command, error),
|
||||
};
|
||||
let retryable = failure.retryable();
|
||||
failures.push(CurlAttemptFailure {
|
||||
attempt,
|
||||
attempts,
|
||||
failure,
|
||||
});
|
||||
if !retryable {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Err(CurlRetryError { attempts: failures })
|
||||
}
|
||||
|
||||
fn classify_failure(exit_code: Option<i32>, http_status: Option<u16>) -> CurlFailureKind {
|
||||
if exit_code == Some(22) {
|
||||
return match http_status {
|
||||
Some(403) => CurlFailureKind::HttpForbidden,
|
||||
Some(404) => CurlFailureKind::HttpNotFound,
|
||||
Some(408 | 425 | 429) => CurlFailureKind::HttpTooManyRequests,
|
||||
Some(400..=499) => CurlFailureKind::HttpClientError,
|
||||
Some(500..=599) => CurlFailureKind::HttpServerError,
|
||||
Some(_) => CurlFailureKind::HttpUnknown,
|
||||
None => CurlFailureKind::HttpUnknown,
|
||||
};
|
||||
}
|
||||
|
||||
match exit_code {
|
||||
Some(5 | 6) => CurlFailureKind::Dns,
|
||||
Some(7) => CurlFailureKind::Connect,
|
||||
Some(18) => CurlFailureKind::Interrupted,
|
||||
Some(28) => CurlFailureKind::Timeout,
|
||||
Some(35) => CurlFailureKind::Tls,
|
||||
Some(52 | 56) => CurlFailureKind::Network,
|
||||
Some(_) => CurlFailureKind::Unknown,
|
||||
None => CurlFailureKind::ProcessFailed,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_http_status(stderr: &str) -> Option<u16> {
|
||||
let bytes = stderr.as_bytes();
|
||||
let mut index = 0usize;
|
||||
while index < bytes.len() {
|
||||
if !bytes[index].is_ascii_digit() {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index;
|
||||
while index < bytes.len() && bytes[index].is_ascii_digit() {
|
||||
index += 1;
|
||||
}
|
||||
if index - start == 3 {
|
||||
let status = stderr[start..index].parse::<u16>().ok()?;
|
||||
if (100..=599).contains(&status) {
|
||||
return Some(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
|
||||
#[test]
|
||||
fn classifies_http_status_codes_from_curl_stderr() {
|
||||
let status = std::process::ExitStatus::from_raw(22 << 8);
|
||||
let output = Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
stderr: b"curl: (22) The requested URL returned error: 404".to_vec(),
|
||||
};
|
||||
let failure = CurlFailure::from_output("https://example.test/a", None, &output);
|
||||
|
||||
assert_eq!(failure.http_status, Some(404));
|
||||
assert_eq!(failure.kind, CurlFailureKind::HttpNotFound);
|
||||
assert!(!failure.retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_5xx_as_retryable() {
|
||||
let status = std::process::ExitStatus::from_raw(22 << 8);
|
||||
let output = Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
stderr: b"curl: (22) The requested URL returned error: 503".to_vec(),
|
||||
};
|
||||
let failure = CurlFailure::from_output("https://example.test/a", None, &output);
|
||||
|
||||
assert_eq!(failure.http_status, Some(503));
|
||||
assert_eq!(failure.kind, CurlFailureKind::HttpServerError);
|
||||
assert!(failure.retryable());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user