Skip to main content

tibba_error/
lib.rs

1// Copyright 2026 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use axum::Json;
16use axum::http::{HeaderValue, StatusCode, header};
17use axum::response::{IntoResponse, Response};
18use serde::{Deserialize, Serialize};
19
20/// 不常用的可选字段集合,装箱存放以控制 [`Error`] 的内存占用。
21/// 仅作为 `Error` 内部实现,不对外暴露。
22///
23/// 每个字段都带 `skip_serializing_if`:未设置的字段不进响应体。此前每个错误
24/// 响应都要拖着 `"code":null,"exception":null,"extra":null` 三段常量噪音——
25/// 对一个**每次出错都会发**的结构而言,这既是带宽浪费,也让前端无法用
26/// 「字段是否存在」判断服务端有没有给出该信息。
27#[derive(Debug, Default, Serialize, Deserialize, Clone)]
28struct ErrorData {
29    /// 错误子分类,用于在同一 category 下进一步区分错误来源。
30    #[serde(skip_serializing_if = "Option::is_none")]
31    sub_category: Option<String>,
32    /// 业务错误码,供前端按码处理特定错误。
33    #[serde(skip_serializing_if = "Option::is_none")]
34    code: Option<String>,
35    /// 是否为需要告警的异常级错误。
36    #[serde(skip_serializing_if = "Option::is_none")]
37    exception: Option<bool>,
38    /// 附加信息列表,可携带多条上下文说明。
39    #[serde(skip_serializing_if = "Option::is_none")]
40    extra: Option<Vec<String>>,
41    /// 是否对客户端隐去 `message` / `extra`;`None` 表示按状态码判定。
42    ///
43    /// `#[serde(skip)]`:这是「如何构造响应」的服务端控制位,不是响应内容本身,
44    /// 不该出现在回给客户端的 JSON 里。
45    #[serde(skip)]
46    redact: Option<bool>,
47}
48
49// 仅用于将 Error 序列化为扁平 JSON 对象的内部视图。
50#[derive(Serialize)]
51struct ErrorSerialize<'a> {
52    /// 未设置分类时不输出,与 [`ErrorData`] 各可选字段的处理一致——
53    /// `"category":""` 与缺省对前端没有区别,只是噪音。
54    #[serde(skip_serializing_if = "str::is_empty")]
55    category: &'a str,
56    message: &'a str,
57    #[serde(flatten)]
58    data: &'a ErrorData,
59}
60
61// 仅用于从扁平 JSON 对象反序列化 Error 的内部视图。
62#[derive(Deserialize)]
63struct ErrorDeserialize {
64    #[serde(default)]
65    category: String,
66    #[serde(default)]
67    message: String,
68    #[serde(flatten)]
69    data: ErrorData,
70}
71
72/// 全局 HTTP 错误类型,贯穿整个应用。
73///
74/// 所有字段均为私有,必须通过 [`Error::new`] 创建并经由链式 `with_xxx` /
75/// `add_xxx` 方法配置;读取使用同名 getter([`Error::status`]、
76/// [`Error::category`] 等)。
77///
78/// 内部把可选字段统一装箱到 `Box<ErrorData>`,将 `Result<_, Error>` 的
79/// `Err` 变体保持在 `clippy::result_large_err` 128 字节限制以内。
80#[derive(Debug, Clone, Default)]
81pub struct Error {
82    /// HTTP 状态码,0 表示未显式设置,`IntoResponse` 时回退为 500。
83    status: u16,
84    /// 错误来源模块或分类,如 "cache"、"db"。
85    category: String,
86    /// 面向用户或日志的错误描述信息。
87    message: String,
88    /// 不常用的可选字段,装箱以控制 `Error` 大小。
89    data: Box<ErrorData>,
90}
91
92impl std::fmt::Display for Error {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(&self.message)
95    }
96}
97
98impl std::error::Error for Error {}
99
100/// 序列化为扁平 JSON 对象:`{ category, message, sub_category?, … }`。
101impl Serialize for Error {
102    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
103    where
104        S: serde::Serializer,
105    {
106        ErrorSerialize {
107            category: &self.category,
108            message: &self.message,
109            data: &self.data,
110        }
111        .serialize(serializer)
112    }
113}
114
115impl<'de> Deserialize<'de> for Error {
116    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
117    where
118        D: serde::Deserializer<'de>,
119    {
120        let d = ErrorDeserialize::deserialize(deserializer)?;
121        Ok(Self {
122            status: 0,
123            category: d.category,
124            message: d.message,
125            data: Box::new(d.data),
126        })
127    }
128}
129
130impl Error {
131    /// 以错误信息创建新的 `Error` 实例,其余字段均为默认值。
132    /// `message` 接受任意 `Display` 类型,便于直接由外部错误包装。
133    #[must_use]
134    pub fn new(message: impl ToString) -> Self {
135        Self {
136            message: message.to_string(),
137            ..Default::default()
138        }
139    }
140
141    // ---------- 链式 setter ----------
142
143    /// 设置错误分类(模块来源),支持链式调用。
144    #[must_use]
145    pub fn with_category(mut self, category: impl Into<String>) -> Self {
146        self.category = category.into();
147        self
148    }
149
150    /// 设置错误子分类,支持链式调用。
151    #[must_use]
152    pub fn with_sub_category(mut self, sub_category: impl Into<String>) -> Self {
153        self.data.sub_category = Some(sub_category.into());
154        self
155    }
156
157    /// 设置业务错误码,支持链式调用。
158    #[must_use]
159    pub fn with_code(mut self, code: impl Into<String>) -> Self {
160        self.data.code = Some(code.into());
161        self
162    }
163
164    /// 设置 HTTP 状态码,支持链式调用。
165    #[must_use]
166    pub fn with_status(mut self, status: u16) -> Self {
167        self.status = status;
168        self
169    }
170
171    /// 标记是否为需要告警的异常级错误,支持链式调用。
172    ///
173    /// **只影响告警**,不影响响应体是否脱敏——脱敏见 [`Self::with_redact`]。
174    #[must_use]
175    pub fn with_exception(mut self, exception: bool) -> Self {
176        self.data.exception = Some(exception);
177        self
178    }
179
180    /// 显式控制是否对客户端隐去 `message` 与 `extra`,支持链式调用。
181    ///
182    /// 不调用本方法时按状态码判定:**5xx 隐去、4xx 保留**。
183    ///
184    /// 之所以要与 [`Self::with_exception`] 分开:两者是独立的轴。`exception`
185    /// 的语义是「需要告警」,不等于「含内部细节」。此前二者被合并判定,
186    /// 一个 `400 + exception` 的错误会回 `400 {"message":"internal server error"}`,
187    /// 状态码与文案自相矛盾,前端无从处理。
188    ///
189    /// 需要覆盖默认时才用它,两个方向都支持:
190    /// - `with_redact(true)`:4xx 但 message 含内部细节(如策略引擎的内部规则)
191    /// - `with_redact(false)`:5xx 但 message 是可安全外露的固定文案
192    #[must_use]
193    pub fn with_redact(mut self, redact: bool) -> Self {
194        self.data.redact = Some(redact);
195        self
196    }
197
198    /// 追加一条附加上下文信息,支持链式调用。
199    #[must_use]
200    pub fn add_extra(mut self, value: impl Into<String>) -> Self {
201        self.data
202            .extra
203            .get_or_insert_with(Vec::new)
204            .push(value.into());
205        self
206    }
207
208    // ---------- getter ----------
209
210    /// HTTP 状态码;返回 0 表示未显式设置,响应时会回退到 500。
211    pub fn status(&self) -> u16 {
212        self.status
213    }
214
215    /// 错误来源模块。
216    pub fn category(&self) -> &str {
217        &self.category
218    }
219
220    /// 面向用户或日志的错误描述。
221    pub fn message(&self) -> &str {
222        &self.message
223    }
224
225    /// 错误子分类,未设置时返回 `None`。
226    pub fn sub_category(&self) -> Option<&str> {
227        self.data.sub_category.as_deref()
228    }
229
230    /// 业务错误码,未设置时返回 `None`。
231    pub fn code(&self) -> Option<&str> {
232        self.data.code.as_deref()
233    }
234
235    /// 是否为需要告警的异常级错误;未显式设置时返回 `false`。
236    pub fn is_exception(&self) -> bool {
237        self.data.exception.unwrap_or(false)
238    }
239
240    /// 是否已显式设置脱敏;`None` 表示按状态码默认判定(见 [`Self::with_redact`])。
241    pub fn redact(&self) -> Option<bool> {
242        self.data.redact
243    }
244
245    /// 附加上下文列表,未设置时返回空切片。
246    pub fn extra(&self) -> &[String] {
247        self.data.extra.as_deref().unwrap_or(&[])
248    }
249
250    /// 由上游服务的 HTTP 错误响应重建 `Error`。
251    ///
252    /// 上游若是同样用本类型的 tibba 服务,`category` / `sub_category` / `code` /
253    /// `extra` 会被**原样保留**;否则退化为「把响应体当纯文本作 message」。
254    ///
255    /// # 为什么 status 从参数来而不是从响应体解析
256    /// 状态码本来就在 HTTP 状态行上,把它再塞进 JSON body 是冗余——也正因如此
257    /// `Error` 的序列化形式里没有它,单纯 `serde_json::from_slice` 得到的 Error
258    /// 状态码是 0(落到 `IntoResponse` 会变成 500)。这个构造函数就是那块拼图:
259    /// 由调用方把状态行上的值传进来,结构化字段从 body 来。
260    ///
261    /// # 注意:这是**上游的**状态码
262    /// 返回的 `Error` 带的是上游给的状态码,不代表本服务该回什么。直接透传给
263    /// 自己的客户端通常是错的(上游 401 不等于调用方未登录)。调用方应显式
264    /// 决定对外状态码,见 `tibba-request` 的 `handle_fail` 如何映射成 502。
265    ///
266    /// 响应体不是本类型的 JSON 时,message 取响应体文本并截断到
267    /// [`MAX_UPSTREAM_MESSAGE_LEN`] 字节——上游回一整页 HTML 错误页是常事,
268    /// 整页塞进 message 只会把日志冲垮。
269    #[must_use]
270    pub fn from_upstream(status: u16, body: &[u8]) -> Self {
271        match serde_json::from_slice::<Self>(body) {
272            // 结构化错误:字段全部保留,只补上状态码
273            Ok(err) if !err.message.is_empty() => err.with_status(status),
274            _ => Self::new(truncate_upstream_body(body)).with_status(status),
275        }
276    }
277}
278
279/// 非结构化上游响应体转成 message 时的长度上限(字节)。
280pub const MAX_UPSTREAM_MESSAGE_LEN: usize = 512;
281
282/// 把上游响应体截成一条能进日志的 message。
283///
284/// 按字符边界截断:直接切字节会在多字节字符中间断开,得到非法 UTF-8。
285fn truncate_upstream_body(body: &[u8]) -> String {
286    if body.is_empty() {
287        return "upstream returned an empty body".to_string();
288    }
289    let text = String::from_utf8_lossy(body);
290    let trimmed = text.trim();
291    if trimmed.len() <= MAX_UPSTREAM_MESSAGE_LEN {
292        return trimmed.to_string();
293    }
294    // floor_char_boundary 尚未稳定,自行往前找最近的字符边界
295    let mut end = MAX_UPSTREAM_MESSAGE_LEN;
296    while end > 0 && !trimmed.is_char_boundary(end) {
297        end -= 1;
298    }
299    format!("{}…", &trimmed[..end])
300}
301
302/// 把 `Error` 上记录的状态码规整成**错误类**响应状态码。
303///
304/// `StatusCode::from_u16` 接受 100..=999 的任何值,于是 `with_status(200)` 的
305/// Error 会以 `200 OK` 发出、带着一个错误 body——客户端按状态码判断成功,
306/// 错误被静默吞掉;`with_status(600)` 这类越界值也会原样上线。错误响应只能
307/// 是 4xx / 5xx,其余一律(含未设置的 0)回退为 500。
308fn response_status(status: u16) -> StatusCode {
309    match StatusCode::from_u16(status) {
310        Ok(code) if code.is_client_error() || code.is_server_error() => code,
311        _ => StatusCode::INTERNAL_SERVER_ERROR,
312    }
313}
314
315/// 脱敏后对外的通用文案:取状态码的标准短语(小写),与状态码保持一致。
316///
317/// 此前固定回 `"internal server error"`,于是一个 `403 + with_redact(true)`
318/// 的错误会回 `403 {"message":"internal server error"}`——状态码说「禁止」,
319/// 文案说「服务器内部错误」,前端无从处理。这正是本文件在 `with_redact` 注释
320/// 里批评过的那种自相矛盾,只是换了个入口又出现了一次。
321fn redacted_message(status: StatusCode) -> String {
322    match status.canonical_reason() {
323        Some(reason) => reason.to_ascii_lowercase(),
324        None if status.is_client_error() => "client error".to_string(),
325        None => "internal server error".to_string(),
326    }
327}
328
329/// 依据状态码与显式设置,判定响应体是否需要隐去 `message` / `extra`。
330///
331/// 默认只看状态码:5xx 的 message 多半是 sqlx / 底层库的原始错误文本,不能外泄;
332/// 4xx 是给调用方看的业务信息,必须保留,否则前端拿不到可处理的原因。
333///
334/// 注意这里**不再**参考 `exception`。它只表示「需要告警」,与「含内部细节」无关,
335/// 详见 [`Error::with_redact`]。
336fn should_redact(status: StatusCode, explicit: Option<bool>) -> bool {
337    explicit.unwrap_or_else(|| status.is_server_error())
338}
339
340/// 将 `Error` 转换为带 JSON 响应体和 `no-cache` 头的 HTTP 响应。
341impl IntoResponse for Error {
342    fn into_response(self) -> Response {
343        let status = response_status(self.status);
344        // 需脱敏时:响应体隐去可能含内部细节的原始 message 与 extra,只回通用文案;
345        // 完整 message 仍随 self 存入 extensions 供服务端日志读取,不外泄给客户端。
346        // category / sub_category / code 属分类信息,保留供前端处理。
347        let mut res = if should_redact(status, self.data.redact) {
348            let redacted = ErrorData {
349                sub_category: self.data.sub_category.clone(),
350                code: self.data.code.clone(),
351                // `exception` 是「要不要告警」的**服务端**分级,客户端没有任何用途,
352                // 却会告诉调用方「这条错误我们内部当事故处理」——在已经决定脱敏的
353                // 响应里外泄这一位,等于把刚藏起来的内部状态又说了一半。
354                // 服务端侧不受影响:完整 Error 仍进 extensions 供 tracker 读取。
355                exception: None,
356                extra: None,
357                // 服务端控制位,不参与序列化,取值无关紧要
358                redact: None,
359            };
360            let message = redacted_message(status);
361            (
362                status,
363                Json(ErrorSerialize {
364                    category: &self.category,
365                    message: &message,
366                    data: &redacted,
367                }),
368            )
369                .into_response()
370        } else {
371            (status, Json(&self)).into_response()
372        };
373        // 把 Error 放入 extensions,方便日志/统计中间件读取上下文
374        res.extensions_mut().insert(self);
375        // 错误响应禁止缓存。用 no-store 而非 no-cache:后者只要求「用前先验证」,
376        // 仍允许浏览器 / 代理把响应**存下来**;错误体里常有账号、资源 ID 之类的
377        // 上下文,不该落进任何缓存。
378        res.headers_mut()
379            .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
380        res
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use pretty_assertions::assert_eq;
388
389    /// 未设分类时不输出空的 `category`,且仍能反序列化回来。
390    #[test]
391    fn empty_category_is_omitted_and_round_trips() {
392        let json = serde_json::to_string(&Error::new("m")).expect("序列化");
393        assert_eq!(json, r#"{"message":"m"}"#);
394        let back: Error = serde_json::from_str(&json).expect("反序列化");
395        assert_eq!(back.category(), "");
396        assert_eq!(back.message(), "m");
397    }
398
399    /// 取出响应体 JSON,供断言脱敏与否。
400    async fn body_json(res: Response) -> serde_json::Value {
401        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
402            .await
403            .expect("读取响应体");
404        serde_json::from_slice(&bytes).expect("响应体应是合法 JSON")
405    }
406
407    #[test]
408    fn redact_defaults_to_status_class_only() {
409        // 默认:5xx 脱敏、4xx 不脱敏
410        assert!(should_redact(StatusCode::INTERNAL_SERVER_ERROR, None));
411        assert!(should_redact(StatusCode::SERVICE_UNAVAILABLE, None));
412        assert!(!should_redact(StatusCode::BAD_REQUEST, None));
413        assert!(!should_redact(StatusCode::NOT_FOUND, None));
414        assert!(!should_redact(StatusCode::OK, None));
415        // 显式设置双向覆盖默认
416        assert!(should_redact(StatusCode::BAD_REQUEST, Some(true)));
417        assert!(!should_redact(
418            StatusCode::INTERNAL_SERVER_ERROR,
419            Some(false)
420        ));
421    }
422
423    /// 回归守卫:`exception` 只管告警,不得触发脱敏。
424    ///
425    /// 此前判定是 `status.is_server_error() || is_exception()`,于是
426    /// 400 + exception 会回 `{"message":"internal server error"}` —— 状态码
427    /// 与文案自相矛盾,前端无从处理。
428    #[tokio::test]
429    async fn exception_on_4xx_keeps_client_facing_message() {
430        let err = Error::new("email format invalid")
431            .with_category("params")
432            .with_status(400)
433            .with_exception(true);
434        let res = err.into_response();
435        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
436
437        let body = body_json(res).await;
438        assert_eq!(body["message"], "email format invalid");
439        assert_eq!(body["category"], "params");
440    }
441
442    #[tokio::test]
443    async fn server_error_message_is_redacted() {
444        let err = Error::new("relation \"users\" does not exist")
445            .with_category("db")
446            .with_sub_category("sqlx")
447            .with_status(500)
448            .add_extra("connection=primary");
449        let res = err.into_response();
450
451        let body = body_json(res).await;
452        assert_eq!(body["message"], "internal server error");
453        // 分类信息保留供前端分流
454        assert_eq!(body["category"], "db");
455        assert_eq!(body["sub_category"], "sqlx");
456        // extra 可能含内部上下文,必须一并隐去
457        assert!(body["extra"].is_null());
458        // 原始 message 不得以任何形式出现在响应体里
459        assert!(!body.to_string().contains("does not exist"));
460    }
461
462    /// 未设 status 时回退 500,同样按 5xx 脱敏(status=0 不是 4xx)。
463    #[tokio::test]
464    async fn unset_status_falls_back_to_redacted_500() {
465        let err = Error::new("raw internal detail").with_category("cache");
466        let res = err.into_response();
467        assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
468        assert_eq!(body_json(res).await["message"], "internal server error");
469    }
470
471    #[tokio::test]
472    async fn explicit_redact_overrides_both_directions() {
473        // 4xx 但强制脱敏
474        let res = Error::new("internal policy rule #42 denied")
475            .with_status(403)
476            .with_redact(true)
477            .into_response();
478        assert_eq!(res.status(), StatusCode::FORBIDDEN);
479        // 脱敏文案与状态码一致,而不是「403 + internal server error」
480        assert_eq!(body_json(res).await["message"], "forbidden");
481
482        // 5xx 但确认文案安全,照常回给客户端
483        let res = Error::new("upstream is warming up, retry later")
484            .with_status(503)
485            .with_redact(false)
486            .into_response();
487        assert_eq!(
488            body_json(res).await["message"],
489            "upstream is warming up, retry later"
490        );
491    }
492
493    /// 完整 Error 始终进 extensions,供日志中间件读取未脱敏的原文。
494    #[test]
495    fn full_error_is_preserved_in_extensions() {
496        let res = Error::new("secret detail").with_status(500).into_response();
497        let stored = res
498            .extensions()
499            .get::<Error>()
500            .expect("Error 应存入 extensions");
501        assert_eq!(stored.message(), "secret detail");
502    }
503
504    #[test]
505    fn no_store_header_is_always_set() {
506        let res = Error::new("x").with_status(400).into_response();
507        assert_eq!(
508            res.headers().get(header::CACHE_CONTROL).unwrap(),
509            "no-store"
510        );
511    }
512
513    /// **回归守卫**:非错误类状态码不得原样发出。
514    ///
515    /// `StatusCode::from_u16` 接受 100..=999,此前 `with_status(200)` 的 Error
516    /// 会回 `200 OK` + 错误 body,客户端按状态码判为成功,错误被静默吞掉。
517    #[test]
518    fn non_error_status_falls_back_to_500() {
519        for bogus in [0, 99, 100, 200, 204, 302, 399, 600, 999] {
520            let res = Error::new("x").with_status(bogus).into_response();
521            assert_eq!(
522                res.status(),
523                StatusCode::INTERNAL_SERVER_ERROR,
524                "status={bogus} 应回退为 500"
525            );
526        }
527        // 合法的 4xx / 5xx 原样保留
528        for ok in [400, 404, 418, 429, 499, 500, 502, 503, 599] {
529            let res = Error::new("x").with_status(ok).into_response();
530            assert_eq!(res.status().as_u16(), ok);
531        }
532    }
533
534    /// 脱敏文案随状态码走,未登记的状态码按大类兜底。
535    #[test]
536    fn redacted_message_matches_status_class() {
537        assert_eq!(
538            redacted_message(StatusCode::INTERNAL_SERVER_ERROR),
539            "internal server error"
540        );
541        assert_eq!(
542            redacted_message(StatusCode::SERVICE_UNAVAILABLE),
543            "service unavailable"
544        );
545        assert_eq!(redacted_message(StatusCode::FORBIDDEN), "forbidden");
546        // 无标准短语的码:4xx 不能说成「服务器内部错误」
547        let unknown_4xx = StatusCode::from_u16(499).expect("合法状态码");
548        assert_eq!(redacted_message(unknown_4xx), "client error");
549        let unknown_5xx = StatusCode::from_u16(599).expect("合法状态码");
550        assert_eq!(redacted_message(unknown_5xx), "internal server error");
551    }
552
553    /// `redact` 是服务端控制位,不得出现在回给客户端的 JSON 里。
554    #[test]
555    fn redact_flag_is_not_serialized() {
556        let json = serde_json::to_string(&Error::new("m").with_redact(true)).unwrap();
557        assert!(!json.contains("redact"), "序列化结果不应含 redact: {json}");
558    }
559
560    /// 未设置的可选字段不得出现在响应体里(此前恒为 `"code":null` 等噪音)。
561    #[tokio::test]
562    async fn unset_optional_fields_are_omitted() {
563        let res = Error::new("bad email")
564            .with_category("params")
565            .with_status(400)
566            .into_response();
567        let body = body_json(res).await;
568        let obj = body.as_object().expect("响应体应是 JSON 对象");
569
570        assert_eq!(obj.len(), 2, "只应剩 category / message,实际: {obj:?}");
571        assert_eq!(obj["category"], "params");
572        assert!(!obj.contains_key("code"));
573        assert!(!obj.contains_key("exception"));
574        assert!(!obj.contains_key("extra"));
575        assert!(!obj.contains_key("sub_category"));
576    }
577
578    /// 脱敏响应不得外泄 `exception`——它只表示服务端要不要告警。
579    #[tokio::test]
580    async fn redacted_response_hides_exception_flag() {
581        let res = Error::new("sqlx: connection refused")
582            .with_category("db")
583            .with_status(500)
584            .with_exception(true)
585            .into_response();
586        let body = body_json(res).await;
587
588        assert_eq!(body["message"], "internal server error");
589        assert!(
590            body.get("exception").is_none(),
591            "脱敏响应仍暴露了告警分级: {body}"
592        );
593        // 服务端侧必须仍拿得到,否则 tracker 无法告警
594    }
595
596    /// 与上一条配套:脱敏不影响服务端读取 `exception`。
597    #[test]
598    fn redacted_response_still_carries_exception_in_extensions() {
599        let res = Error::new("boom")
600            .with_status(500)
601            .with_exception(true)
602            .into_response();
603        let stored = res
604            .extensions()
605            .get::<Error>()
606            .expect("Error 应存入 extensions");
607        assert!(stored.is_exception());
608    }
609
610    /// 上游是同用本类型的 tibba 服务时,结构化字段必须一路带回来。
611    ///
612    /// 此前跨服务只能靠 `json_get(body, "message")` 取一个字符串,
613    /// category / sub_category / code / extra 全丢,status 也归 0(落地成 500)。
614    #[test]
615    fn from_upstream_restores_structured_fields() {
616        let upstream = Error::new("account is locked")
617            .with_category("user")
618            .with_sub_category("locked")
619            .with_code("E4031")
620            .add_extra("retry_after=600")
621            .with_status(403);
622        let body = serde_json::to_vec(&upstream).expect("序列化上游错误");
623
624        let restored = Error::from_upstream(403, &body);
625        assert_eq!(restored.message(), "account is locked");
626        assert_eq!(restored.category(), "user");
627        assert_eq!(restored.sub_category(), Some("locked"));
628        assert_eq!(restored.code(), Some("E4031"));
629        assert_eq!(restored.extra(), ["retry_after=600".to_string()]);
630        // 状态码来自状态行参数,补上了序列化丢失的那一块
631        assert_eq!(restored.status(), 403);
632    }
633
634    /// 上游不是 tibba 服务时退化为纯文本,且仍带上状态码。
635    #[test]
636    fn from_upstream_falls_back_to_plain_text() {
637        let err = Error::from_upstream(502, b"  <html>Bad Gateway</html>  ");
638        assert_eq!(err.message(), "<html>Bad Gateway</html>");
639        assert_eq!(err.status(), 502);
640
641        // 合法 JSON 但不是本类型(没有 message 字段)同样走回退
642        let err = Error::from_upstream(400, br#"{"error":"invalid_grant"}"#);
643        assert_eq!(err.status(), 400);
644        assert!(err.message().contains("invalid_grant"));
645
646        // 空响应体
647        let err = Error::from_upstream(504, b"");
648        assert_eq!(err.status(), 504);
649        assert!(!err.message().is_empty(), "空 body 也要有可读的 message");
650    }
651
652    /// 上游回一整页 HTML 是常事,整页塞进 message 会把日志冲垮。
653    #[test]
654    fn from_upstream_truncates_oversized_body() {
655        let body = "x".repeat(MAX_UPSTREAM_MESSAGE_LEN * 4);
656        let err = Error::from_upstream(500, body.as_bytes());
657        assert!(
658            err.message().len() <= MAX_UPSTREAM_MESSAGE_LEN + 4,
659            "实际长度 {}",
660            err.message().len()
661        );
662        assert!(err.message().ends_with('…'));
663    }
664
665    /// 截断必须按字符边界,切在多字节字符中间会产生非法 UTF-8。
666    #[test]
667    fn from_upstream_truncation_respects_char_boundaries() {
668        // 每个汉字 3 字节,上限不是 3 的倍数,必然切在字符中间
669        let body = "错".repeat(MAX_UPSTREAM_MESSAGE_LEN);
670        let err = Error::from_upstream(500, body.as_bytes());
671        // 能取到 &str 本身就说明没切坏;再确认确实被截断了
672        assert!(err.message().chars().all(|c| c == '错' || c == '…'));
673        assert!(err.message().len() <= MAX_UPSTREAM_MESSAGE_LEN + 4);
674    }
675
676    #[test]
677    fn serde_round_trip_preserves_public_fields() {
678        let err = Error::new("boom")
679            .with_category("db")
680            .with_sub_category("sqlx")
681            .with_code("E1001")
682            .with_exception(true)
683            .add_extra("a")
684            .add_extra("b");
685        let json = serde_json::to_string(&err).unwrap();
686        let back: Error = serde_json::from_str(&json).unwrap();
687
688        assert_eq!(back.message(), "boom");
689        assert_eq!(back.category(), "db");
690        assert_eq!(back.sub_category(), Some("sqlx"));
691        assert_eq!(back.code(), Some("E1001"));
692        assert!(back.is_exception());
693        assert_eq!(back.extra(), ["a".to_string(), "b".to_string()]);
694        // status 不参与序列化,跨服务传递会丢失(既有行为)
695        assert_eq!(back.status(), 0);
696    }
697}