P1-1(p1-1items 的根因): - download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与 `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有 Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」 取最可诊断的那个(**不从 suppressed 文本猜状态码**)。 - download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。 - online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是 NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。 - import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开; `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。 - import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN, Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此 只会在真断网时出现。 P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用 `core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`; `HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`, `InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。 P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回 `content_install_request`,详情方法只留自己那一行。 P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的 项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到 结果清掉。 顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过; 两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的 ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。 用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条 (AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、 单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」 (共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
251 lines
8.9 KiB
Rust
251 lines
8.9 KiB
Rust
//! 下载错误:三态分类(同源重试 / 立刻换源 / 整体失败)+ 失败摘要。
|
||
//!
|
||
//! core 不引 `thiserror`(P0 阶段少一个依赖),手写 `Display`/`Error` 足够。
|
||
|
||
use std::fmt;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// 错误分类:决定「同源重试」「立刻换源」还是「整体失败」。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum DownloadErrorKind {
|
||
/// 请求本身不合法(不重试)。
|
||
InvalidRequest,
|
||
/// 404/410/403:该源没有这个资源,立刻换源。
|
||
NotFound,
|
||
/// 5xx / 429:同源退避重试。
|
||
ServerError,
|
||
/// 超时:同源退避重试,多次后换源。
|
||
Timeout,
|
||
/// 连接/传输层错误。
|
||
Network,
|
||
/// 校验不符:同源重试一次后换源(可能是镜像内容不一致)。
|
||
Integrity,
|
||
/// 服务器不支持 Range(内部用于退化单连接,不向上暴露为失败)。
|
||
RangeUnsupported,
|
||
/// 本地 IO 错误(不重试)。
|
||
Io,
|
||
/// 所有候选源都失败。
|
||
AllSourcesFailed,
|
||
}
|
||
|
||
impl DownloadErrorKind {
|
||
/// 稳定字符串 id。
|
||
#[must_use]
|
||
pub const fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::InvalidRequest => "invalid_request",
|
||
Self::NotFound => "not_found",
|
||
Self::ServerError => "server_error",
|
||
Self::Timeout => "timeout",
|
||
Self::Network => "network",
|
||
Self::Integrity => "integrity",
|
||
Self::RangeUnsupported => "range_unsupported",
|
||
Self::Io => "io",
|
||
Self::AllSourcesFailed => "all_sources_failed",
|
||
}
|
||
}
|
||
|
||
/// 是否值得在同源上退避重试。
|
||
#[must_use]
|
||
pub const fn is_retryable(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::ServerError | Self::Timeout | Self::Network | Self::Integrity
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 下载错误。
|
||
#[derive(Debug, Clone)]
|
||
pub struct DownloadError {
|
||
/// 分类。
|
||
pub kind: DownloadErrorKind,
|
||
/// 出错的源(整体失败时为 `None`)。
|
||
pub source: Option<String>,
|
||
/// 人类可读描述。
|
||
pub message: String,
|
||
/// 已经尝试的次数(含本次)。
|
||
pub attempts: usize,
|
||
/// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
|
||
pub suppressed: Vec<String>,
|
||
/// **叶子成因**:整体失败([`DownloadErrorKind::AllSourcesFailed`])之前,各候选源到底
|
||
/// 是怎么挂的。
|
||
///
|
||
/// 为什么需要它:候选源循环结束后只剩一条 `AllSourcesFailed`,上层(在线内容源、
|
||
/// 导入补全、自更新)拿到它就无法区分「这个项目不存在(404)」与「没联网」——
|
||
/// 前者要提示换关键词,后者要提示检查网络。`suppressed` 里虽然有文字,但从错误串里
|
||
/// 反解状态码是靠文案猜,改一句提示就失效。
|
||
///
|
||
/// 汇总规则([`leaf_of`]):全 404 ⇒ `NotFound`;出现 `Network`/`Timeout` ⇒ `Network`;
|
||
/// 全 5xx ⇒ `ServerError`;混合按「网络错 > 5xx > 404」取最可诊断的那个。
|
||
/// `None` = 没有汇总过(单源直接失败,或调用方自己构造的错误)。
|
||
pub leaf: Option<DownloadErrorKind>,
|
||
}
|
||
|
||
impl DownloadError {
|
||
/// 构造错误。
|
||
#[must_use]
|
||
pub fn new(kind: DownloadErrorKind, message: impl Into<String>) -> Self {
|
||
Self {
|
||
kind,
|
||
source: None,
|
||
message: message.into(),
|
||
attempts: 0,
|
||
suppressed: Vec::new(),
|
||
leaf: None,
|
||
}
|
||
}
|
||
|
||
/// 标注出错的源。
|
||
#[must_use]
|
||
pub fn with_source(mut self, source: impl Into<String>) -> Self {
|
||
self.source = Some(source.into());
|
||
self
|
||
}
|
||
|
||
/// 标注尝试次数。
|
||
#[must_use]
|
||
pub fn with_attempts(mut self, attempts: usize) -> Self {
|
||
self.attempts = attempts;
|
||
self
|
||
}
|
||
|
||
/// 汇总其它源的失败摘要。
|
||
#[must_use]
|
||
pub fn with_suppressed(mut self, suppressed: Vec<String>) -> Self {
|
||
self.suppressed = suppressed;
|
||
self
|
||
}
|
||
|
||
/// 标注整体失败之前的叶子成因。
|
||
#[must_use]
|
||
pub const fn with_leaf(mut self, leaf: DownloadErrorKind) -> Self {
|
||
self.leaf = Some(leaf);
|
||
self
|
||
}
|
||
|
||
/// 最可诊断的成因:有叶子就用叶子,否则用自身的 kind。
|
||
///
|
||
/// 上层(`addon::online::transport` / `addon::import::fetch`)靠这一处判定,不各自
|
||
/// 再写一遍「AllSourcesFailed 到底算哪一类」。
|
||
#[must_use]
|
||
pub fn effective_kind(&self) -> DownloadErrorKind {
|
||
if self.kind == DownloadErrorKind::AllSourcesFailed {
|
||
return self.leaf.unwrap_or(self.kind);
|
||
}
|
||
self.kind
|
||
}
|
||
}
|
||
|
||
/// 从各候选源的失败里汇总出**最可诊断**的叶子成因。
|
||
///
|
||
/// 规则(与 `online::transport` 的映射表配套,判据只此一处):
|
||
///
|
||
/// | 各源的情况 | 叶子 |
|
||
/// |------------|------|
|
||
/// | 全部 404/410/403(`NotFound`) | `NotFound` |
|
||
/// | 出现 `Network` / `Timeout` | `Network` |
|
||
/// | 全部 5xx(`ServerError`) | `ServerError` |
|
||
/// | 其它(`Integrity` / `Io` / `InvalidRequest`) | 该条自己的 kind(后出现的赢) |
|
||
/// | 混合了 404 与 5xx | `ServerError`(比「不存在」更可诊断) |
|
||
///
|
||
/// `kinds` 为空时返回 `AllSourcesFailed`(调用方据此保留原 kind)。
|
||
#[must_use]
|
||
pub fn leaf_of(kinds: impl IntoIterator<Item = DownloadErrorKind>) -> DownloadErrorKind {
|
||
let mut leaf = DownloadErrorKind::AllSourcesFailed;
|
||
let mut rank = 0u8;
|
||
for kind in kinds {
|
||
let (candidate, weight) = match kind {
|
||
DownloadErrorKind::Network | DownloadErrorKind::Timeout => {
|
||
(DownloadErrorKind::Network, 4)
|
||
}
|
||
DownloadErrorKind::ServerError => (DownloadErrorKind::ServerError, 3),
|
||
DownloadErrorKind::Integrity => (DownloadErrorKind::Integrity, 3),
|
||
DownloadErrorKind::Io => (DownloadErrorKind::Io, 3),
|
||
DownloadErrorKind::InvalidRequest => (DownloadErrorKind::InvalidRequest, 3),
|
||
DownloadErrorKind::NotFound => (DownloadErrorKind::NotFound, 1),
|
||
DownloadErrorKind::RangeUnsupported | DownloadErrorKind::AllSourcesFailed => (kind, 2),
|
||
};
|
||
if weight >= rank {
|
||
rank = weight;
|
||
leaf = candidate;
|
||
}
|
||
}
|
||
leaf
|
||
}
|
||
|
||
impl fmt::Display for DownloadError {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match &self.source {
|
||
Some(source) => write!(
|
||
f,
|
||
"[{}] {}(源 {})",
|
||
self.kind.as_str(),
|
||
self.message,
|
||
source
|
||
),
|
||
None => write!(f, "[{}] {}", self.kind.as_str(), self.message),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for DownloadError {}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{leaf_of, DownloadError, DownloadErrorKind};
|
||
|
||
/// 四档叶子成因(tower 定案的门禁):全 404 / 有网络错 / 全 5xx / 混合。
|
||
#[test]
|
||
fn leaf_keeps_the_most_diagnosable_cause() {
|
||
use DownloadErrorKind::{Network, NotFound, ServerError, Timeout};
|
||
assert_eq!(leaf_of([NotFound, NotFound]), NotFound, "全 404 ⇒ NotFound");
|
||
assert_eq!(
|
||
leaf_of([Network, NotFound]),
|
||
Network,
|
||
"有网络错 ⇒ Network(哪怕另一源是 404)"
|
||
);
|
||
assert_eq!(
|
||
leaf_of([ServerError, ServerError]),
|
||
ServerError,
|
||
"全 5xx ⇒ ServerError"
|
||
);
|
||
assert_eq!(
|
||
leaf_of([NotFound, ServerError]),
|
||
ServerError,
|
||
"混合 404 与 5xx ⇒ 取更可诊断的那个"
|
||
);
|
||
assert_eq!(leaf_of([Timeout, NotFound]), Network, "超时归到 Network");
|
||
assert_eq!(
|
||
leaf_of([]),
|
||
DownloadErrorKind::AllSourcesFailed,
|
||
"一条都没有 ⇒ 保留原 kind"
|
||
);
|
||
}
|
||
|
||
/// 整体失败时上层靠 `effective_kind()` 判定;单源失败时就是它自己的 kind。
|
||
#[test]
|
||
fn effective_kind_prefers_the_leaf_only_for_all_sources_failed() {
|
||
let leaf = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
|
||
.with_leaf(DownloadErrorKind::NotFound);
|
||
assert_eq!(leaf.effective_kind(), DownloadErrorKind::NotFound);
|
||
assert_eq!(
|
||
leaf.kind,
|
||
DownloadErrorKind::AllSourcesFailed,
|
||
"kind 本身不改"
|
||
);
|
||
|
||
let bare = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败");
|
||
assert_eq!(
|
||
bare.effective_kind(),
|
||
DownloadErrorKind::AllSourcesFailed,
|
||
"没有叶子 ⇒ 原样返回(上层再决定当哪一类)"
|
||
);
|
||
|
||
let single = DownloadError::new(DownloadErrorKind::NotFound, "404");
|
||
assert_eq!(single.effective_kind(), DownloadErrorKind::NotFound);
|
||
}
|
||
}
|