把 unwrap() 理解成「错误处理的快捷方式」会在生产里立刻失效。它是断言:这里不可能失败,失败就崩。库把错误擦成 String 或一律 anyhow,调用方就失去 match 的权利,只能把内部故障当成同一句 500。错误类型是 API 的一部分,和返回的 User 一样,决定谁能决策、谁只能汇报。

这篇文章只讲错误的类型形状:Result 如何强迫处理、thiserror 如何把 IO 收进领域、anyhow 在哪一层才该出现、panic 和错误的边界、如何映射到 HTTP/RPC 而不泄漏内部。读完你应该能独立回答三个问题:这个 crate 的公开 E 该不该是 anyhow::Error、? 传出去之前丢了什么上下文、为什么测试里的 expect 写了不变量而生产路径上的 unwrap 没有。

1. Result 是控制流,不是装饰

Result<T, E> 把失败做成值。? 是「失败就返回」,不是「失败就忽略」。类型上,调用方必须写 match、?、或显式 unwrap。这和异常的差别不在「漂不漂亮」,在于失败路径是签名的一部分,重构时编译器会追上每一个调用点。

E 选什么,决定调用方能做什么:

  • 具体枚举:能区分 NotFound 和 PermissionDenied,能决定重试、降级、404。
  • io::Error:能看 ErrorKind,但丢失「当时在做什么」。
  • Box<dyn Error + Send + Sync>:能打印和 source() 链,不能稳定 match。
  • anyhow::Error:同上,外加 context 和 backtrace 便利,仍然不能当领域决策。

生产事故里一类经典是:底层是 Connection reset,中间层 map_err(|_| "failed"),最外层日志只有 failed。你不是没有错误处理,是把决策信息在每一层剥掉。? 很便宜,剥信息也很便宜。要的是 ? 之前把「做什么」留在类型或 context 里。

fn load_user(id: u64) -> Result<User, anyhow::Error> {
    let raw = std::fs::read(format!("users/{id}"))?;
    let user = serde_json::from_slice(&raw)?;
    Ok(user)
}

这个函数能跑。失败时你分不清是没这个文件、JSON 坏了、还是权限。应用边界可以暂时靠 anyhow 的 context 补「当时在 load_user」;库不能把这个签名公开出去——调用方无法 match 出 404。

2. 两层错误,两个方向

稳定的分层是:

  • 库 / 领域层:thiserror 生成的枚举(或手写 enum + Display + Error)。每个变体是调用方可能做出不同决策的一种失败。From 把底层错误收进来,但只收那些你打算保留或包装的。
  • 二进制入口、请求边界、批处理主循环:anyhow::Result 或等价物(eyre、color-eyre)。这里的任务是汇报:带上 context、打印链、退出码、Sentry。不再做领域 match。
use thiserror::Error;

#[derive(Debug, Error)]
pub enum StoreError {
    #[error("user {0} not found")]
    NotFound(u64),
    #[error("busy, try later")]
    Busy,
    #[error("io")]
    Io(#[from] std::io::Error),
}

fn load_user(id: u64) -> Result<User, StoreError> {
    let path = format!("users/{id}");
    let raw = match std::fs::read(&path) {
        Ok(v) => v,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(StoreError::NotFound(id));
        }
        Err(e) => return Err(e.into()),
    };
    serde_json::from_slice(&raw).map_err(|e| {
        std::io::Error::new(std::io::ErrorKind::InvalidData, e).into()
    })
}

NotFound 和 Busy 是领域。IO 被收进 Io,调用方若只关心「要不要重试」,可以再提供 fn is_retryable(&self) -> bool 而不是让每个人去 match Io(e) 的 ErrorKind。JSON 在这里被收成 InvalidData——这是一种选择:库认为损坏的文件和坏 IO 对调用方是同类。若调用方必须区分,就再加 Corrupt 变体,不要偷懒全进 Io。

thiserror 干的是脏活:Display、Error::source、From。它不替你设计变体。变体设计是产品问题:调用方到底有几种不同的反应?反应相同的失败,不该是不同变体。反应不同却被收成一个 Other(String),类型系统帮不上忙。

3. 不要把字符串当错误协议

Err("oops".into())、anyhow!("something {}", x)、Box<dyn Error> 从 String 造出来,都是把协议写成散文。散文适合人读,不适合机器决策。重试器不能 if err.to_string().contains("timeout")——文案一改,重试策略就静默失效。

稳定的对外协议是:错误码 / 枚举 / gRPC status / HTTP 状态 + 机器可读的 code 字段。Display 给日志和人。两者不要用同一根字符串走完全程。

把错误当协议来演一次版本升级:你把 Timeout 的文案从「上游超时」改成「网关超时」,如果客户端或重试器在 contains("上游") 上做判断,行为会静默改变。code() 仍是 timeout,机器路径不应受文案影响。这就是为什么 Display 和 code 必须分开,以及为什么 code 的changelog 要当 breaking 来写。 错误里携带的动态数据(用户 id、路径、对端地址)用结构化字段,不要拼进 Display 当唯一来源。日志系统按字段检索;拼进句子里的 id 很难再被仪表盘分组。字段还要分级:对端地址进日志,不一定进对用户的响应。泄漏不是只有 SQL 才算,内部主机名和磁盘路径同样是泄漏。 thiserror 的 #[error("... {0}")] 会把内部错误的 Display 嵌进来。嵌底层 io::Error 的操作系统文案可能是英文、可能含路径。对外响应不要直接用这个 Display。对外用稳定中文或稳定英文短句,对内用链。两套文案,两套读者。 重试器、熔断器、限流器都是错误类型的消费者。它们不该知道 HTTP。给它们 is_retryable()、is_overload()、retry_after() 就够。在 axum 层再把同一枚举映射成状态码。两层都 match 同一枚举,比一层转字符串另一层再解析健壮。缺方法时加方法,不要让基础设施去猜。 测试里对每个变体断言四件事:status、code、retryable、日志级别。一张表,二十行,能挡住八成「全是 500」和「告警被 404 淹没」。这张表比任何错误处理框架都值钱。

#[derive(Debug, Error)]
pub enum RouteError {
    #[error("not found: {0}")]
    NotFound(String),
    #[error("upstream timeout")]
    Timeout,
    #[error("unavailable")]
    Unavailable,
}

impl RouteError {
    pub fn http_status(&self) -> u16 {
        match self {
            RouteError::NotFound(_) => 404,
            RouteError::Timeout => 504,
            RouteError::Unavailable => 503,
        }
    }

    pub fn code(&self) -> &'static str {
        match self {
            RouteError::NotFound(_) => "not_found",
            RouteError::Timeout => "timeout",
            RouteError::Unavailable => "unavailable",
        }
    }
}

对客户端只给 code + 安全的消息。内部 source 链、路径、SQL、对端主机名,写进日志和 tracing span,不写进响应体。把 Display 直接塞进 HTTP 是信息泄漏,也是不稳定协议——你改了句中文文案,客户端解析就碎。

non_exhaustive 用在库的公开错误枚举上:你以后加变体,旧的调用方 match 必须有 _。这是在承认错误集合会演化。应用内部的枚举不必如此,漏掉一个变体让编译器提醒你更新 HTTP 映射,反而是好事。

4. anyhow 的正当位置,以及它擦掉了什么

anyhow::Error 是带 vtable 的错误对象,外加 context 栈。with_context(|| format!("loading user {id}")) 在失败路径上才分配那句字符串。它适合:

  • main、CLI、批处理任务;
  • HTTP 请求的最外层 handler,已经决定只记日志 + 500 分类;
  • 胶水代码,把三个不同库的错误收口。

它不适合:

  • 库的公开签名;
  • 需要 match 决定 404/409/429 的那一层;
  • 热路径上作为「可能失败」的常见分支(每次失败都装箱、都可能抓 backtrace)。

anyhow 和 thiserror 不是互斥宗教。同一进程里,store crate 返回 StoreError,axum handler 里 map_err 成 HTTP,或 anyhow::Context 包一层再在边界转换成状态码。转换发生的那一层叫反腐败层,不要把 anyhow 往下渗透到仓库实现里「图省事」。

eyre / color-eyre / snafu 是同一设计空间的不同口味。snafu 更强调上下文枚举,适合想保持类型又想自动加 context 的库。选一个,写进工程约定。不要每个 crate 一种,错误链会在日志里变成三种格式。

5. From、map_err、context:三层不同的丢失

? 依赖 From。From<io::Error> for StoreError 意味着所有 IO 失败在这个边界上语义相同。这很方便,也很危险:超时、拒绝、未找到,都会变成同一个 Io。需要区分时,不要实现笼统 From,写显式 match 再 ?。

map_err 是一次性转换。适合「这里的 IO 失败就是 NotFound」。写完要看是否丢了 source。thiserror 的 #[from] / #[source] 会把底层留在链上,map_err(|_| RouteError::Unavailable) 会把底层扔掉。扔掉之前确认日志已经记过,或你把底层放进变体。

context 不改类型(对 anyhow),只在链上加一层「当时在做什么」。它解决的是 Display 不够、但类型已经擦掉的那一层。领域层优先加变体或字段(user_id),不要用散文 context 替代 NotFound(u64)。数字能 match,句子不能。

失败路径的分配:format! 一条 context、Box 一个 dyn、backtrace 展开,在错误罕见时无所谓。把错误当控制流(「文件不存在是常态」)时,这些都会进热路径。常态失败应该是枚举变体,不装箱,不抓栈。NotFound 不是异常。

把「失败」和「没找到」在类型上分开之后,调用方的代码会干净很多:缓存层返回 Option,仓库层在「按 id 读取必须存在」的 API 上才抬成 NotFound。同一底层查找,两个签名,两种语义。不要图省事只提供 Result 版,让所有调用方都走错误通道,日志和 SLO 会被「正常的没有」填满。 错误路径的测试数据要像生产:超长字符串、非 UTF-8、磁盘满的 ErrorKind、被取消的 JoinError。只用 anyhow!("boom") 当假错误,测不到映射表,也测不到泄漏的 Display。注入真实 kind,断言对外 code 和日志字段,这才是错误类型的契约测试。

6. panic、unwrap、expect、unreachable

panic 是进程级的「不变量破了」。栈展开、drop、可能 abort(见构建篇的 panic = abort)。跨 FFI 的 panic 是未定义行为。在 tonic/axum 的请求任务里 panic,默认会把这个任务打死,不一定崩进程——于是你得到一个 500 和一个「task panicked」日志,没有类型化错误。这不是策略,是漏网。

  • unwrap:生产路径禁止,除非你能用一行注释证明此处数学上不可能 Err/None,并且失败意味着内存损坏或编程错误。即便如此,expect("队列只在 drop 后关闭") 更好,因为它留下不变量。
  • expect:测试、构建器、进程启动时的必有配置。消息写不变量,不写「出错了」。
  • unreachable! / todo!:前者给编译器证明这个分支关了;后者不许进生产二进制。
  • unwrap_or_default:失败被静默成空值。配置解析失败变成空配置,比 panic 更阴险。

启动路径上 expect 是正当的:端口绑定失败、必填环境变量缺失,进程就不该进就绪探针。请求路径上同样的 expect 是放大故障——一个坏请求不该带走 worker 上碰巧共享的状态。请求路径返回 Result,由边界映射。

debug_assert! 只在 debug 活着。不要用它检查必须在 release 也成立的协议不变量。恶意输入不是 debug-only 的。

7. 错误与并发、取消、部分失败

异步和扇出让错误从「一个 Result」变成「一组」。

  • 一个子任务失败,其它怎么办:fail-fast 取消剩余(JoinSet::abort_all),还是收集全部 Result 再汇总。产品语义,不是风格。
  • 取消不是错误。JoinError::is_cancelled()、select! 丢掉的分支,不要记成 ERROR。否则告警系统会被部署和超时淹没。
  • 部分写:先写磁盘再写索引,中间失败要有补偿或事务。错误类型要能表达「可能已经产生副作用」——这和函数式的纯 Err 不一样。签名里用 CommitError { written: u64 } 这种变体,比一句 failed 值钱。
  • 通道关闭:SendError 表示接收方没了,通常是正常关闭路径,不一定是故障。match 它,不要 ? 到最外层变成 500。
async fn write_both(a: &Path, b: &Path, bytes: &[u8]) -> Result<(), CommitError> {
    std::fs::write(a, bytes).map_err(CommitError::First)?;
    std::fs::write(b, bytes).map_err(|e| CommitError::Second { first_ok: true, source: e })?;
    Ok(())
}

调用方看到 Second { first_ok: true } 知道要清理 a。类型把补偿信息留下来。这就是「错误类型是 API」。

8. 映射到 HTTP / RPC / 退出码

边界上的映射表应该穷尽领域错误,而不是 to_string():

  • NotFound → 404 / gRPC NotFound
  • Busy / 限流 → 429 / ResourceExhausted,带 Retry-After
  • Timeout → 504 / DeadlineExceeded
  • Unavailable → 503
  • 校验失败 → 400 / InvalidArgument
  • 未预期的 Io / 内部 → 500 / Internal,日志记满链,响应只给 correlation id

退出码同理:配置错误、用法错误、真正的内部故障,对 systemd/K8s 是不同的重启策略。一律 exit(1) 会让探针和崩溃循环分不清。

不要用 HTTP 状态当内部错误类型。领域层不知道 HTTP。否则你的仓库 crate 依赖 axum,测试和复用都脏。映射表住在 http 适配层,一层 match,可以测。

tracing:失败时 error!(error = %e, code = e.code(), user_id, "load_user failed")。把 user_id 放字段里,不要只放进字符串。后续检索、告警分组、采样都靠字段。Display 链用 error!(?e) 或 source 循环打,保证底层 io::ErrorKind 还在。

9. 取舍:类型精度、样板、分配、演化

精度和样板是一对:变体越多,match 越有价值,每个调用点越烦。收口成 Other 越早,决策越早消失。经验:按调用方的不同反应来拆,不按底层 errno 来拆。十个 errno 若都是「重试或 503」,一个 Unavailable 加 source 就够。

分配:枚举不含 String 就不在成功路径分配。变体里的 String/Box 只在失败时构造。热路径上的可预期失败(cache miss 不要当错误;Option 不是 Result)不要走错误通道。cache miss 是 Option::None,不是 Err。

演化:库的公开错误加变体是 breaking(除非 non_exhaustive)。把会变的细节放进 source 或内部结构,公开只暴露稳定的分类方法 is_not_found() / is_retryable()。调用方依赖方法而不是穷尽 match,你内部就能改。

thiserror vs 手写:变体少、要精确控制 source,手写也没几行。变体多、要 From,thiserror 减少复制粘贴。不要因为「不用宏更纯洁」手写二十个 Display。

10. 排障:丢失的上下文、被吞的错误、把常态当异常

  1. 日志只有 error 两个字。 某层 map_err(|_| ...) 或 to_string() 后丢了 source。从边界往下找第一个转换,补 #[source] 或 context。
  2. 全是 500。 领域错误在中间被收成 anyhow,handler 不再 match。在 handler 或专门的 IntoResponse 里对领域枚举映射,anyhow 只承接未预期。
  3. 重试了不可重试的。 校验失败、NotFound 被 is_retryable 误标。用测试钉映射表:每个变体是否可重试、对应哪个状态码。
  4. 错误洪水。 把取消、正常关闭、客户端断开当 ERROR。降到 debug/info,告警只留真正的内部故障。
  5. 偶发 panic。 unwrap 在「我认为不会」的切片、锁中毒、解析上。锁中毒(Mutex::lock().unwrap())是另一任务已经 panic 过——先修那个 panic,再决定中毒策略(into_inner 还是传播)。
  6. 性能测试里错误路径很重。 backtrace 在 anyhow 里默认某些 feature 会抓。关闭或只在边界抓。热失败改枚举。

单测:对每个公开变体测一次映射。集成测:故意让下游 500/超时/断开,看你的服务返回码和日志字段。不要只测成功 JSON。

11. 和所有权、调度的交界

错误值也会被 move 进 channel、跨 await。E: Send + 'static 才能进 spawn。在错误里塞 &str 指向局部缓冲,和在成功值里塞一样,编不过或成为悬空。错误里需要的标识(id、路径)在失败那一刻拷进 String 或用已有的 Bytes,不要借。

spawn 的 JoinError 要把子任务的 E 和「任务 panic / 取消」分开。? 一个 JoinError 到 HTTP 层,会把取消变成 500。显式 match。

持锁时返回 Err 要先想守卫 drop 的顺序:? 会 drop 守卫,这通常是对的。不要把 MutexGuard 放进错误变体带出去。

把错误当 API 来设计时,还要决定哪些失败对调用方是可预期的控制流。打开文件不存在、缓存未命中、CAS 竞争失败,这些在领域里是日常。用 Result 还是 Option 取决于调用方要不要看原因:未命中没有原因,用 Option;未命中可能是「没有」或「后端超时」,用 Result。不要把 Option 当错误处理,也不要把所有 None 抬成 Err。ok_or(NotFound) 是显式抬升,发生在你确实决定「没有」对这个 API 是错误的那一层,而不是在每一层防御性抬升。

错误链的打印顺序要统一。Display 写本层,source() 指下一层。日志里从外往内走:while let Some(s) = err.source()。anyhow 的 {:#} 会打出链。不要每层 Display 都把底层 e 再格式化一遍,否则同一句 IO 错误出现三次,检索时像三件事。thiserror 的 #[error("io: {0}")] 若 0 已经是 io::Error,再加 #[from] 和 #[source] 时注意不要重复。只 #[from] 通常已经把 source 接好。

Box<dyn Error + Send + Sync + 'static> 是库不想暴露细节、应用又还没引入 anyhow 时的折中。它能跨线程、能进 spawn,不能稳定 match。一旦你发现调用方在 downcast_ref 上写了一堆 if,就是在用运行时反射补类型——该回到枚举了。downcast 对插件边界偶尔正当(对岸真的是未知类型),对自家的三个 crate 不正当。

文档里每个公开 Err 变体要写:是否可重试、是否可能已有副作用、对应的稳定 code。这比写「失败时返回错误」有用。示例代码展示 match,不要只展示 ?。? 教不会调用方决策。

重试不是错误类型的一部分,但错误类型必须能支撑重试器。重试器要问:可不可重试、建议等多久、已经有没有副作用。is_retryable() 和 retry_after() 比让重试器去解析 Display 稳定。幂等的 GET 超时可重试;非幂等的 POST 在「不确定有没有到达」时重试会双写——错误变体要能区分 Timeout { maybe_committed: bool } 这类含糊失败和确定的 Unavailable。含糊失败走查询、去重键或人工,不走盲目重试。把这套规则写成函数,用表驱动测试钉住每一个变体,比在三个调用点各写一套 match 安全。

观测性上,错误要当字段,不当一整段文本。tracing::error!(code = err.code(), retryable = err.is_retryable(), error = %err, "request failed") 让告警按 code 分组。同一 code 的突发才是事故;Display 里每次不同的 id 会把告警系统打成「全是独一无二的错误」。correlation id / trace id 进响应,原文进日志。用户看到「请求失败,id=…」,运维用这个 id 把链捞出来。不要让用户看到 Os { code: 2, kind: NotFound, message: "No such file or directory" }——这既泄漏又无用。

From 实现的方向只能是「更底层 → 更靠近领域」。From<StoreError> for anyhow::Error 可以;From<anyhow::Error> for StoreError 几乎永远不该有,因为它在用被擦掉的类型重建决策,只能变成 Other。一旦有 Other(anyhow::Error),调用方又回到字符串协议。胶水层用 map_err 显式写转换,比双向 From 诚实。孤儿规则会阻止你给外部类型实现外部 trait,这是好事——转换函数放在你的适配层,名字叫 to_http / to_store_error,审查时能搜到。

panic hook 和错误处理是另一条边。std::panic::set_hook 把 panic 打进 tracing,便于任务 panic 时留下字段。hook 里不要做重 IO、不要抢可能已经损坏的锁。catch_unwind 只包你能恢复的半径:单个请求的处理函数可以包,进程级的运行时初始化不要包。包了却在半初始化的全局上继续,比崩掉更糟。FFI 边界必须包,那是正确性;请求边界可选包,那是隔离。两者不要用同一套「全能 hook」糊弄过去。

测试策略按层:单元测试覆盖每个变体的 Display、source、http_status、is_retryable。属性测试随机生成嵌套的 From 转换,断言链上总能找到某类 source。集成测试注入真实的 IO 失败(文件权限、连接重置、超时),看边界映射而不是看 mock 返回的字符串。没有失败注入的测试套件,等于只测了 Ok 路径,错误类型写得再精致也是没上过战场。

错误与日志级别的映射要稳定。可预期的客户端错误(4xx 对应的那些变体)走 warn 或 info,否则一次扫描器就能把错误率和 on-call 打爆。真正的内部故障(磁盘满、对端协议破坏、不变量破)走 error。取消和断开走 debug。级别写在变体旁边,和 http_status 一起测。谁在 handler 里一律 error!(%e),谁就把这个表作废。采样:高频的 NotFound 可以记计数不记全文,低频的 Io 记全文加链。存储和检索成本是错误处理的一部分,不是运维以后再想的事。

must_use 用在返回 Result 的函数上,防止调用方丢弃失败。let _ = foo(); 是显式忽略,评审要问为什么。异步函数返回的 future 本身也是 must_use,不 await 也不 spawn 的 do_x(); 是「以为执行了其实没执行」。这不是错误类型的问题,但症状一样:失败路径从未存在。clippy 的 unused_must_use、let_underscore_future 应当在 workspace lints 里打开。

领域错误不要继承基础设施的分类法。io::ErrorKind::NotFound 是内核的「没有这个文件」;业务的 NotFound 是「没有这个用户」。中间层把前者翻译成后者必须显式,因为「配置文件缺失」不该变成 HTTP 404 给终端用户。翻译发生的位置就是反腐败层。测试这个翻译:临时文件删掉,断言返回的是 500 或启动失败,而不是 404。分类看起来相同、语义不同,是错误设计里最阴险的一类。

版本演化时,旧客户端可能不认识新 code。公开 API 加变体要么 major,要么 non_exhaustive 加 Unknown 兜底。线协议上的错误码用稳定整数或短字符串,不要用 Rust 枚举的 Debug 名。Debug 名重构时会变,客户端的 if code == "StoreError::NotFound" 会静默失效。code() -> &'static str 的字符串是协议,改它等于改 API。文档和 changelog 要当协议变更来写。

错误类型一旦进入 match、重试器和告警分组,再合并变体或改 code() 字符串就是协议破坏。内部改名只动 Rust 标识符,code() 不要用 stringify! 偷懒。第三方错误直接出现在公开枚举里,等于公开 API 绑死那个 crate 的版本——包一层。收的时候问:调用方会不会对这个底层失败做出不同反应、要不要 source 链、会不会把对岸细节冻成我们的 API。三个都否就 Internal 加 source。 map / and_then 长链的失败点只能靠 backtrace,panic = abort 时可能没有展开。生产优先 ? 加少量 match。启动期缺配置应当让进程非零退出,请求期同样的 IO 失败返回 503。把启动期 expect 抄到请求路径,坏请求会把探针打死;把请求期「记日志继续」用到启动期,你会带着半初始化全局跑。分界就是 run().await 之前和之后。 SLO 若说 5xx 低于千分之一,枚举必须能区分 4xx 和 5xx。校验失败算进 5xx,SLO 永远达不到,然后有人放宽阈值,真故障一起被放宽。映射表是 SLO 的实现。客户端错误走 warn/info,内部故障走 error,取消走 debug,和 http_status 一起测。高频 NotFound 记计数不记全文。must_use 防止丢弃 Result;异步函数不 await 也不 spawn 是另一类「失败路径从未存在」。 领域 NotFound 不是 io::ErrorKind::NotFound。配置文件缺失翻译成给用户的 404,是分类看起来相同、语义不同。翻译发生在反腐败层,用注入真实 IO 失败的测试钉住。

12. 小结

错误处理的原理是 类型保留决策权。谁能决策(重试、404、补偿),谁就拿具体 E;谁只负责汇报,谁就在边界 context 收口。unwrap 是断言不变量,不是快捷方式;字符串不是协议;常态失败不是 panic。

库用 thiserror 把反应不同的失败写成变体,应用用 anyhow 或一层 match 把变体变成状态码和日志字段。

一个可以立刻落地的检查表:公开 crate 的 E 能不能被 match;应用边界有没有把 source 链留下;请求路径有没有 unwrap;映射表是否每个变体都有测试;取消和客户端断开有没有被当成 ERROR。五项里有一项否,错误处理就还停留在「我们用了 thiserror」的口号上。口号过不了 on-call。错误处理做完的标志,是一次故障里你能用 code 分组、用字段检索、用映射表解释为什么是 503 而不是 500。做不到这三件事,类型写得再漂亮也还没进生产。

下一篇跨过语言边界:C ABI 上没有 Result,也没有 panic 的合法通道,所有权必须写在注释和安全封装里,再由类型在 Rust 这一侧重新闭合。

如果只能带走一条:把错误当成公开 API 来做 code review。变体是不是对应不同反应、code 会不会随文案改、请求路径有没有 unwrap、取消有没有被记成 ERROR。这四问比「我们要不要换 snafu」更接近事故。换框架不修分层,分层错了换什么都是字符串协议。