Skip to main content

yggdrasil/utils/
error.rs

1//! 将类型化的 API 错误整理成给人看的失败提示。
2//!
3//! 消息只用于展示,错误分类仅检查 [`ApiError`](crate::api::error::ApiError) 的枚举变体。
4//! 框架错误可能带原始响应或内部细节,不能直接展示。
5
6use leptos::prelude::ServerFnError;
7
8use crate::api::error::ApiError;
9
10/// 服务端没给具体原因时的说法(`AppError` 对查询与事务错误统一返回它),不再重复展示。
11const GENERIC_REASON: &str = "操作失败";
12
13/// 无法判断具体原因时的说法。
14const UNAVAILABLE_REASON: &str = "服务暂时不可用,请稍后再试";
15
16/// 可以展示给用户的失败原因;网络中断给固定说法,拿不到具体原因时为 `None`。
17pub fn error_reason(err: &ApiError) -> Option<String> {
18    match err {
19        ApiError::Unauthorized(message)
20        | ApiError::Forbidden(message)
21        | ApiError::NotFound(message)
22        | ApiError::BadRequest(message)
23        | ApiError::Internal(message) => {
24            let message = message.trim();
25            (!message.is_empty() && message != GENERIC_REASON).then(|| message.to_string())
26        }
27        ApiError::Unavailable => Some("服务暂时不可用".to_string()),
28        ApiError::Framework(ServerFnError::Request(_)) => {
29            Some("网络连接中断,请检查网络后重试".to_string())
30        }
31        // 响应体不是 server_fn 的错误编码时(反向代理返回 HTML 错误页等),解码失败,
32        // 错误消息里带着原始响应体。正文不能展示,只按其中出现的状态码说明。
33        ApiError::Framework(
34            ServerFnError::Deserialization(message) | ServerFnError::Response(message),
35        ) => {
36            let reason = ["413", "429", "502", "503", "504"]
37                .iter()
38                .find(|code| message.contains(**code))
39                .and_then(|code| code.parse().ok().and_then(status_reason))
40                .unwrap_or(UNAVAILABLE_REASON);
41            Some(reason.to_string())
42        }
43        _ => None,
44    }
45}
46
47fn status_reason(code: u16) -> Option<&'static str> {
48    match code {
49        413 => Some("内容超过服务器允许的大小"),
50        429 => Some("请求过于频繁,请稍后再试"),
51        502..=504 => Some(UNAVAILABLE_REASON),
52        _ => None,
53    }
54}
55
56/// 失败提示:有具体原因时为「保存失败:标题不能为空」,否则为「保存失败,请稍后再试」。
57pub fn failure_message(action: &str, err: &ApiError) -> String {
58    match error_reason(err) {
59        Some(reason) => format!("{action}:{reason}"),
60        None => format!("{action},请稍后再试"),
61    }
62}
63
64/// 只识别公开契约中的「资源不存在」,消息不参与判断。
65pub fn is_not_found(err: &ApiError) -> bool {
66    matches!(err, ApiError::NotFound(_))
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn business_reason_and_generic_failure_are_presented_separately() {
75        let error = ApiError::BadRequest("标题不能为空".into());
76        assert_eq!(
77            failure_message("保存失败", &error),
78            "保存失败:标题不能为空"
79        );
80        let missing = ApiError::NotFound("文章不存在".into());
81        assert_eq!(
82            failure_message("加载失败", &missing),
83            "加载失败:文章不存在"
84        );
85        for message in ["操作失败", "  "] {
86            let error = ApiError::Internal(message.into());
87            assert_eq!(failure_message("删除失败", &error), "删除失败,请稍后再试");
88        }
89    }
90
91    #[test]
92    fn unparseable_client_failures_never_leak_internal_text() {
93        let err = ApiError::Framework(ServerFnError::Deserialization(
94            "expected value at line 1".to_string(),
95        ));
96        let text = failure_message("发布失败", &err);
97        assert_eq!(text, "发布失败:服务暂时不可用,请稍后再试");
98        assert!(!text.contains("error"));
99        assert!(!text.contains("line 1"));
100    }
101
102    #[test]
103    fn non_json_error_pages_are_described_by_status() {
104        let too_large = ApiError::Framework(ServerFnError::Deserialization(
105            "Invalid format: missing delimiter in \"<html><title>413 Request Entity Too Large</title>\""
106                .to_string(),
107        ));
108        assert_eq!(
109            error_reason(&too_large).as_deref(),
110            Some("内容超过服务器允许的大小")
111        );
112        let proxy = ApiError::Framework(ServerFnError::Response(
113            "<h1>502 Bad Gateway</h1>".to_string(),
114        ));
115        assert_eq!(
116            failure_message("登录失败", &proxy),
117            "登录失败:服务暂时不可用,请稍后再试"
118        );
119        let busy = ApiError::Framework(ServerFnError::Deserialization(
120            "<h1>429 Too Many Requests</h1>".to_string(),
121        ));
122        assert_eq!(
123            failure_message("登录失败", &busy),
124            "登录失败:请求过于频繁,请稍后再试"
125        );
126        let unknown = ApiError::Framework(ServerFnError::Deserialization(
127            "<p>418 teapot</p>".to_string(),
128        ));
129        assert_eq!(
130            failure_message("登录失败", &unknown),
131            "登录失败:服务暂时不可用,请稍后再试"
132        );
133    }
134
135    #[test]
136    fn request_errors_read_as_network_problems() {
137        let err = ApiError::Framework(ServerFnError::Request("connection reset".to_string()));
138        assert_eq!(
139            failure_message("回复失败", &err),
140            "回复失败:网络连接中断,请检查网络后重试"
141        );
142    }
143
144    #[test]
145    fn other_variants_have_no_reason() {
146        let err = ApiError::Framework(ServerFnError::Args("missing field".to_string()));
147        assert_eq!(error_reason(&err), None);
148        assert_eq!(failure_message("保存失败", &err), "保存失败,请稍后再试");
149    }
150
151    #[test]
152    fn not_found_requires_the_typed_category() {
153        assert!(is_not_found(&ApiError::NotFound("文章不存在".into())));
154        for error in [
155            ApiError::BadRequest("not_found:文章不存在".into()),
156            ApiError::Internal("not_found".into()),
157            ApiError::Framework(ServerFnError::Request("not_found:x".into())),
158            ApiError::Framework(ServerFnError::ServerError("not_found".into())),
159        ] {
160            assert!(!is_not_found(&error));
161        }
162    }
163}