1use axum::Json;
16use axum::http::{HeaderValue, StatusCode, header};
17use axum::response::{IntoResponse, Response};
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Default, Serialize, Deserialize, Clone)]
28struct ErrorData {
29 #[serde(skip_serializing_if = "Option::is_none")]
31 sub_category: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 code: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 exception: Option<bool>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 extra: Option<Vec<String>>,
41 #[serde(skip)]
46 redact: Option<bool>,
47}
48
49#[derive(Serialize)]
51struct ErrorSerialize<'a> {
52 #[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#[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#[derive(Debug, Clone, Default)]
81pub struct Error {
82 status: u16,
84 category: String,
86 message: String,
88 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
100impl 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 #[must_use]
134 pub fn new(message: impl ToString) -> Self {
135 Self {
136 message: message.to_string(),
137 ..Default::default()
138 }
139 }
140
141 #[must_use]
145 pub fn with_category(mut self, category: impl Into<String>) -> Self {
146 self.category = category.into();
147 self
148 }
149
150 #[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 #[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 #[must_use]
166 pub fn with_status(mut self, status: u16) -> Self {
167 self.status = status;
168 self
169 }
170
171 #[must_use]
175 pub fn with_exception(mut self, exception: bool) -> Self {
176 self.data.exception = Some(exception);
177 self
178 }
179
180 #[must_use]
193 pub fn with_redact(mut self, redact: bool) -> Self {
194 self.data.redact = Some(redact);
195 self
196 }
197
198 #[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 pub fn status(&self) -> u16 {
212 self.status
213 }
214
215 pub fn category(&self) -> &str {
217 &self.category
218 }
219
220 pub fn message(&self) -> &str {
222 &self.message
223 }
224
225 pub fn sub_category(&self) -> Option<&str> {
227 self.data.sub_category.as_deref()
228 }
229
230 pub fn code(&self) -> Option<&str> {
232 self.data.code.as_deref()
233 }
234
235 pub fn is_exception(&self) -> bool {
237 self.data.exception.unwrap_or(false)
238 }
239
240 pub fn redact(&self) -> Option<bool> {
242 self.data.redact
243 }
244
245 pub fn extra(&self) -> &[String] {
247 self.data.extra.as_deref().unwrap_or(&[])
248 }
249
250 #[must_use]
270 pub fn from_upstream(status: u16, body: &[u8]) -> Self {
271 match serde_json::from_slice::<Self>(body) {
272 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
279pub const MAX_UPSTREAM_MESSAGE_LEN: usize = 512;
281
282fn 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 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
302fn 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
315fn 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
329fn should_redact(status: StatusCode, explicit: Option<bool>) -> bool {
337 explicit.unwrap_or_else(|| status.is_server_error())
338}
339
340impl IntoResponse for Error {
342 fn into_response(self) -> Response {
343 let status = response_status(self.status);
344 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: None,
356 extra: None,
357 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 res.extensions_mut().insert(self);
375 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 #[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 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 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 assert!(should_redact(StatusCode::BAD_REQUEST, Some(true)));
417 assert!(!should_redact(
418 StatusCode::INTERNAL_SERVER_ERROR,
419 Some(false)
420 ));
421 }
422
423 #[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 assert_eq!(body["category"], "db");
455 assert_eq!(body["sub_category"], "sqlx");
456 assert!(body["extra"].is_null());
458 assert!(!body.to_string().contains("does not exist"));
460 }
461
462 #[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 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 assert_eq!(body_json(res).await["message"], "forbidden");
481
482 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 }
595
596 #[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 #[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 assert_eq!(restored.status(), 403);
632 }
633
634 #[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 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 let err = Error::from_upstream(504, b"");
648 assert_eq!(err.status(), 504);
649 assert!(!err.message().is_empty(), "空 body 也要有可读的 message");
650 }
651
652 #[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 #[test]
667 fn from_upstream_truncation_respects_char_boundaries() {
668 let body = "错".repeat(MAX_UPSTREAM_MESSAGE_LEN);
670 let err = Error::from_upstream(500, body.as_bytes());
671 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 assert_eq!(back.status(), 0);
696 }
697}