1use axum::http::{HeaderValue, StatusCode, header};
46use axum::response::{IntoResponse, Response};
47use serde::Serialize;
48
49#[derive(Debug)]
54struct StringError(String);
55
56impl std::fmt::Display for StringError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str(&self.0)
59 }
60}
61
62impl std::error::Error for StringError {}
63
64#[derive(Clone, Debug, Serialize)]
66pub struct ProblemDetails {
67 #[serde(rename = "type")]
70 pub type_uri: String,
71 pub title: String,
73 pub status: u16,
75 pub detail: String,
77 pub instance: Option<String>,
79 pub code: String,
81 pub request_id: Option<String>,
83 pub errors: Vec<ProblemFieldError>,
85}
86
87#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
89pub struct ProblemFieldError {
90 pub field: String,
92 pub messages: Vec<String>,
94}
95
96pub struct AutumnError {
136 inner: Box<dyn std::error::Error + Send + Sync>,
137 status: StatusCode,
138 details: Option<std::collections::HashMap<String, Vec<String>>>,
139 problem_type: Option<&'static str>,
140 cache_idempotency_response: bool,
141 #[cfg(debug_assertions)]
144 pub(crate) backtrace_string: Option<String>,
145}
146
147pub type AutumnResult<T> = Result<T, AutumnError>;
163
164impl<E> From<E> for AutumnError
165where
166 E: std::error::Error + Send + Sync + 'static,
167{
168 fn from(err: E) -> Self {
169 let mut status = StatusCode::INTERNAL_SERVER_ERROR;
170 let any_err: &dyn std::any::Any = &err;
171
172 if std::any::type_name::<E>().contains("CircuitBreakerError")
173 && err.to_string() == "circuit breaker is open"
174 {
175 status = StatusCode::SERVICE_UNAVAILABLE;
176 }
177
178 #[cfg(feature = "http-client")]
179 {
180 if matches!(
181 any_err.downcast_ref::<crate::http_client::ClientError>(),
182 Some(crate::http_client::ClientError::CircuitBreakerOpen)
183 ) {
184 status = StatusCode::SERVICE_UNAVAILABLE;
185 }
186 }
187
188 #[cfg(feature = "mail")]
189 {
190 if let Some(crate::mail::MailError::RuntimeUnavailable(msg)) =
191 any_err.downcast_ref::<crate::mail::MailError>()
192 && msg.contains("circuit breaker is open")
193 {
194 status = StatusCode::SERVICE_UNAVAILABLE;
195 }
196 }
197
198 Self {
199 inner: Box::new(err),
200 status,
201 details: None,
202 problem_type: None,
203 cache_idempotency_response: false,
204 #[cfg(debug_assertions)]
205 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
206 }
207 }
208}
209
210impl AutumnError {
211 #[must_use]
224 pub const fn with_status(mut self, status: StatusCode) -> Self {
225 self.status = status;
226 self
227 }
228
229 pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
241 Self {
242 inner: Box::new(err),
243 status: StatusCode::INTERNAL_SERVER_ERROR,
244 details: None,
245 problem_type: None,
246 cache_idempotency_response: false,
247 #[cfg(debug_assertions)]
248 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
249 }
250 }
251
252 pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
264 Self {
265 inner: Box::new(err),
266 status: StatusCode::NOT_FOUND,
267 details: None,
268 problem_type: None,
269 cache_idempotency_response: false,
270 #[cfg(debug_assertions)]
271 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
272 }
273 }
274
275 pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
287 Self {
288 inner: Box::new(err),
289 status: StatusCode::BAD_REQUEST,
290 details: None,
291 problem_type: None,
292 cache_idempotency_response: false,
293 #[cfg(debug_assertions)]
294 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
295 }
296 }
297
298 pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
313 Self {
314 inner: Box::new(err),
315 status: StatusCode::UNPROCESSABLE_ENTITY,
316 details: None,
317 problem_type: None,
318 cache_idempotency_response: false,
319 #[cfg(debug_assertions)]
320 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
321 }
322 }
323
324 pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
336 Self {
337 inner: Box::new(err),
338 status: StatusCode::SERVICE_UNAVAILABLE,
339 details: None,
340 problem_type: None,
341 cache_idempotency_response: false,
342 #[cfg(debug_assertions)]
343 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
344 }
345 }
346
347 pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
359 Self {
360 inner: Box::new(err),
361 status: StatusCode::UNAUTHORIZED,
362 details: None,
363 problem_type: None,
364 cache_idempotency_response: false,
365 #[cfg(debug_assertions)]
366 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
367 }
368 }
369
370 pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
382 Self {
383 inner: Box::new(err),
384 status: StatusCode::FORBIDDEN,
385 details: None,
386 problem_type: None,
387 cache_idempotency_response: false,
388 #[cfg(debug_assertions)]
389 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
390 }
391 }
392
393 #[must_use]
415 pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
416 Self {
417 inner: Box::new(StringError("Validation failed".into())),
418 status: StatusCode::UNPROCESSABLE_ENTITY,
419 details: Some(details),
420 problem_type: None,
421 cache_idempotency_response: false,
422 #[cfg(debug_assertions)]
423 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
424 }
425 }
426
427 pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
441 Self::internal_server_error(StringError(msg.into()))
442 }
443
444 pub fn not_found_msg(msg: impl Into<String>) -> Self {
457 Self::not_found(StringError(msg.into()))
458 }
459
460 pub fn bad_request_msg(msg: impl Into<String>) -> Self {
472 Self::bad_request(StringError(msg.into()))
473 }
474
475 pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
487 Self::unprocessable(StringError(msg.into()))
488 }
489
490 pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
502 Self::unauthorized(StringError(msg.into()))
503 }
504
505 pub fn forbidden_msg(msg: impl Into<String>) -> Self {
517 Self::forbidden(StringError(msg.into()))
518 }
519
520 pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
532 Self::service_unavailable(StringError(msg.into()))
533 }
534
535 pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
550 Self {
551 inner: Box::new(err),
552 status: StatusCode::CONFLICT,
553 details: None,
554 problem_type: Some("https://autumn.dev/problems/conflict"),
555 cache_idempotency_response: false,
556 #[cfg(debug_assertions)]
557 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
558 }
559 }
560
561 pub fn conflict_msg(msg: impl Into<String>) -> Self {
573 Self::conflict(StringError(msg.into()))
574 }
575
576 pub fn gone(err: impl std::error::Error + Send + Sync + 'static) -> Self {
588 Self {
589 inner: Box::new(err),
590 status: StatusCode::GONE,
591 details: None,
592 problem_type: Some("https://autumn.dev/problems/gone"),
593 cache_idempotency_response: false,
594 #[cfg(debug_assertions)]
595 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
596 }
597 }
598
599 pub fn gone_msg(msg: impl Into<String>) -> Self {
611 Self::gone(StringError(msg.into()))
612 }
613
614 pub fn query_timeout(msg: impl Into<String>) -> Self {
631 Self {
632 inner: Box::new(StringError(msg.into())),
633 status: StatusCode::SERVICE_UNAVAILABLE,
634 details: None,
635 problem_type: Some("https://autumn.dev/problems/query-timeout"),
636 cache_idempotency_response: false,
637 #[cfg(debug_assertions)]
638 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
639 }
640 }
641
642 #[must_use]
654 pub const fn status(&self) -> StatusCode {
655 self.status
656 }
657
658 #[doc(hidden)]
659 #[must_use]
660 pub(crate) const fn cache_idempotency_response(mut self) -> Self {
661 self.cache_idempotency_response = true;
662 self
663 }
664
665 #[must_use]
670 pub fn source_chain(&self) -> Vec<String> {
671 let mut chain = Vec::new();
672 let mut source = self.inner.source();
673 while let Some(error) = source {
674 chain.push(error.to_string());
675 source = error.source();
676 }
677 chain
678 }
679
680 #[must_use]
682 pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
683 let err: &(dyn std::error::Error + 'static) = self.inner.as_ref();
684 err.downcast_ref::<T>()
685 }
686}
687
688impl std::fmt::Display for AutumnError {
689 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
690 write!(f, "{}", self.inner)
691 }
692}
693
694impl std::fmt::Debug for AutumnError {
695 #[allow(clippy::missing_fields_in_debug)]
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 f.debug_struct("AutumnError")
698 .field("status", &self.status)
699 .field("inner", &self.inner)
700 .field("details", &self.details)
701 .field("problem_type", &self.problem_type)
702 .field(
703 "cache_idempotency_response",
704 &self.cache_idempotency_response,
705 )
706 .finish_non_exhaustive()
707 }
708}
709
710impl ProblemDetails {
711 #[must_use]
713 pub fn new(
714 status: StatusCode,
715 detail: impl Into<String>,
716 details: Option<&std::collections::HashMap<String, Vec<String>>>,
717 ) -> Self {
718 problem_details(status, detail.into(), details, None, None, None, true)
719 }
720}
721
722#[must_use]
724pub(crate) fn problem_details(
725 status: StatusCode,
726 detail: String,
727 details: Option<&std::collections::HashMap<String, Vec<String>>>,
728 explicit_type: Option<&'static str>,
729 request_id: Option<String>,
730 instance: Option<String>,
731 expose_internal_detail: bool,
732) -> ProblemDetails {
733 let has_validation_errors = details.is_some_and(|map| !map.is_empty());
734 let safe_detail = if status.is_server_error() && !expose_internal_detail {
735 server_error_detail(status)
736 } else {
737 detail
738 };
739
740 let code = explicit_type.map_or_else(
747 || problem_code_for(status, has_validation_errors).to_owned(),
748 |etype| {
749 let slug = etype.rsplit('/').next().unwrap_or(etype);
750 format!("autumn.{}", slug.replace('-', "_"))
751 },
752 );
753
754 ProblemDetails {
755 type_uri: explicit_type
756 .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
757 .to_owned(),
758 title: problem_title_for(status, has_validation_errors).to_owned(),
759 status: status.as_u16(),
760 detail: safe_detail,
761 instance,
762 code,
763 request_id,
764 errors: validation_errors(details),
765 }
766}
767
768#[must_use]
771pub(crate) fn problem_details_json_string(
772 status: StatusCode,
773 detail: impl Into<String>,
774 details: Option<&std::collections::HashMap<String, Vec<String>>>,
775 explicit_type: Option<&'static str>,
776 request_id: Option<String>,
777 instance: Option<String>,
778 expose_internal_detail: bool,
779) -> String {
780 let problem = problem_details(
781 status,
782 detail.into(),
783 details,
784 explicit_type,
785 request_id,
786 instance,
787 expose_internal_detail,
788 );
789 problem_details_to_json_string(&problem)
790}
791
792#[must_use]
794pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
795 serde_json::to_string(&problem).unwrap_or_else(|_| {
796 r#"{"type":"https://autumn.dev/problems/internal-server-error","title":"Internal Server Error","status":500,"detail":"Internal server error","instance":null,"code":"autumn.internal_server_error","request_id":null,"errors":[]}"#.to_owned()
797 })
798}
799
800fn validation_errors(
801 details: Option<&std::collections::HashMap<String, Vec<String>>>,
802) -> Vec<ProblemFieldError> {
803 let mut errors: Vec<_> = details
804 .into_iter()
805 .flat_map(std::collections::HashMap::iter)
806 .map(|(field, messages)| ProblemFieldError {
807 field: field.clone(),
808 messages: messages.clone(),
809 })
810 .collect();
811 errors.sort_by(|left, right| left.field.cmp(&right.field));
812 errors
813}
814
815const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
816 if has_validation_errors {
817 return "https://autumn.dev/problems/validation-failed";
818 }
819
820 match status {
821 StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
822 StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
823 StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
824 StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
825 StatusCode::GONE => "https://autumn.dev/problems/gone",
826 StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
827 StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
828 StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
829 StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
830 StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
831 StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
832 _ => "about:blank",
833 }
834}
835
836fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
837 if has_validation_errors {
838 return "Validation Failed";
839 }
840
841 match status {
842 StatusCode::BAD_REQUEST => "Bad Request",
843 StatusCode::UNAUTHORIZED => "Unauthorized",
844 StatusCode::FORBIDDEN => "Forbidden",
845 StatusCode::NOT_FOUND => "Not Found",
846 StatusCode::GONE => "Gone",
847 StatusCode::CONFLICT => "Conflict",
848 StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
849 StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
850 StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
851 StatusCode::NOT_IMPLEMENTED => "Not Implemented",
852 StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
853 _ => status.canonical_reason().unwrap_or("Error"),
854 }
855}
856
857fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
858 if has_validation_errors {
859 return "autumn.validation_failed";
860 }
861
862 match status {
863 StatusCode::BAD_REQUEST => "autumn.bad_request",
864 StatusCode::UNAUTHORIZED => "autumn.unauthorized",
865 StatusCode::FORBIDDEN => "autumn.forbidden",
866 StatusCode::NOT_FOUND => "autumn.not_found",
867 StatusCode::GONE => "autumn.gone",
868 StatusCode::CONFLICT => "autumn.conflict",
869 StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
870 StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
871 StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
872 StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
873 StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
874 _ if status.is_client_error() => "autumn.client_error",
875 _ if status.is_server_error() => "autumn.server_error",
876 _ => "autumn.error",
877 }
878}
879
880fn server_error_detail(status: StatusCode) -> String {
881 match status {
882 StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
883 StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
884 _ => "Internal server error".to_owned(),
885 }
886}
887
888impl IntoResponse for AutumnError {
889 fn into_response(self) -> Response {
890 let mut status = self.status;
891 let message = self.inner.to_string();
892 let mut problem_type = self.problem_type;
893
894 let err_str = message.to_lowercase();
896 if err_str.contains("57014")
897 || err_str.contains("query_canceled")
898 || err_str.contains("canceling statement due to statement timeout")
899 || err_str.contains("statement timeout")
900 || err_str.contains("query canceled")
901 {
902 status = StatusCode::SERVICE_UNAVAILABLE;
903 problem_type = Some("https://autumn.dev/problems/query-timeout");
904 }
905
906 let details = self.details.clone();
907 let cache_idempotency_response = self.cache_idempotency_response;
908
909 let error_info = crate::middleware::AutumnErrorInfo {
912 status,
913 message: message.clone(),
914 details: details.clone(),
915 problem_type,
916 #[cfg(debug_assertions)]
917 backtrace_string: self.backtrace_string.clone(),
918 #[cfg(not(debug_assertions))]
919 backtrace_string: None,
920 };
921
922 let body = problem_details(
923 status,
924 message,
925 details.as_ref(),
926 problem_type,
927 None,
928 None,
929 true,
930 );
931 let mut response = (status, axum::Json(body)).into_response();
932 response.headers_mut().insert(
933 header::CONTENT_TYPE,
934 HeaderValue::from_static("application/problem+json"),
935 );
936 if status == StatusCode::CONFLICT {
937 response.headers_mut().insert(
938 "HX-Trigger",
939 HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
940 );
941 }
942 if cache_idempotency_response {
943 response
944 .extensions_mut()
945 .insert(crate::idempotency::IdempotencyCacheCommittedErrorResponse);
946 }
947 response.extensions_mut().insert(error_info);
948 response
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use axum::http::StatusCode;
956
957 #[derive(Debug)]
958 struct TestError(String);
959
960 impl std::fmt::Display for TestError {
961 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
962 write!(f, "{}", self.0)
963 }
964 }
965
966 impl std::error::Error for TestError {}
967
968 #[derive(Debug)]
969 struct WrappedError {
970 message: String,
971 source: TestError,
972 }
973
974 impl std::fmt::Display for WrappedError {
975 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
976 write!(f, "{}", self.message)
977 }
978 }
979
980 impl std::error::Error for WrappedError {
981 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
982 Some(&self.source)
983 }
984 }
985
986 #[test]
987 fn blanket_from_defaults_to_500() {
988 let err: AutumnError = TestError("boom".into()).into();
989 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
990 }
991
992 #[test]
993 fn internal_server_error_is_500() {
994 let err = AutumnError::internal_server_error(TestError("boom".into()));
995 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
996 }
997
998 #[test]
999 fn test_not_found_error() {
1000 let err = AutumnError::not_found(std::io::Error::other("no such user"));
1001 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1002 }
1003
1004 #[test]
1005 fn not_found_is_404() {
1006 let err = AutumnError::not_found(TestError("missing".into()));
1007 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1008 }
1009
1010 #[test]
1011 fn bad_request_is_400() {
1012 let err = AutumnError::bad_request(TestError("invalid input".into()));
1013 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1014 }
1015
1016 #[test]
1017 fn unprocessable_is_422() {
1018 let err = AutumnError::unprocessable(TestError("bad entity".into()));
1019 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1020 }
1021
1022 #[test]
1023 fn unauthorized_is_401() {
1024 let err = AutumnError::unauthorized(TestError("unauthorized".into()));
1025 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1026 }
1027
1028 #[test]
1029 fn forbidden_is_403() {
1030 let err = AutumnError::forbidden(TestError("forbidden".into()));
1031 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1032 }
1033
1034 #[test]
1035 fn validation_is_422() {
1036 let mut details = std::collections::HashMap::new();
1037 details.insert("field".to_string(), vec!["error".to_string()]);
1038 let err = AutumnError::validation(details);
1039 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1040 }
1041
1042 #[test]
1043 fn service_unavailable_is_503() {
1044 let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
1045 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1046 }
1047
1048 #[test]
1049 fn internal_server_error_msg_is_500() {
1050 let err = AutumnError::internal_server_error_msg("db failure");
1051 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1052 assert_eq!(err.to_string(), "db failure");
1053 }
1054
1055 #[test]
1056 fn not_found_msg_is_404() {
1057 let err = AutumnError::not_found_msg("no such user");
1058 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1059 assert_eq!(err.to_string(), "no such user");
1060 }
1061
1062 #[test]
1063 fn bad_request_msg_is_400() {
1064 let err = AutumnError::bad_request_msg("invalid input");
1065 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1066 }
1067
1068 #[test]
1069 fn unprocessable_msg_is_422() {
1070 let err = AutumnError::unprocessable_msg("title required");
1071 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1072 }
1073
1074 #[test]
1075 fn unauthorized_msg_is_401() {
1076 let err = AutumnError::unauthorized_msg("login required");
1077 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1078 }
1079
1080 #[test]
1081 fn forbidden_msg_is_403() {
1082 let err = AutumnError::forbidden_msg("no access");
1083 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1084 }
1085
1086 #[test]
1087 fn service_unavailable_msg_is_503() {
1088 let err = AutumnError::service_unavailable_msg("db down");
1089 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1090 assert_eq!(err.to_string(), "db down");
1091 }
1092
1093 #[test]
1094 fn with_status_overrides() {
1095 let err: AutumnError = TestError("forbidden".into()).into();
1096 let err = err.with_status(StatusCode::FORBIDDEN);
1097 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1098 }
1099
1100 #[test]
1101 fn display_uses_inner_message() {
1102 let err: AutumnError = TestError("something broke".into()).into();
1103 assert_eq!(err.to_string(), "something broke");
1104 }
1105
1106 #[test]
1107 fn source_chain_lists_inner_sources() {
1108 let err = AutumnError::internal_server_error(WrappedError {
1109 message: "failed to backfill".to_string(),
1110 source: TestError("database connection dropped".to_string()),
1111 });
1112
1113 assert_eq!(
1114 err.source_chain(),
1115 vec!["database connection dropped".to_string()]
1116 );
1117 }
1118
1119 #[test]
1120 fn into_response_has_correct_status() {
1121 let err = AutumnError::not_found(TestError("not found".into()));
1122 let response = err.into_response();
1123 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1124 }
1125
1126 #[tokio::test]
1127 async fn into_response_has_json_body() -> Result<(), axum::Error> {
1128 let err = AutumnError::not_found(TestError("not found".into()));
1129 let response = err.into_response();
1130
1131 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1132 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1133
1134 assert_eq!(json["status"], 404);
1135 assert_eq!(json["detail"], "not found");
1136 assert_eq!(json["code"], "autumn.not_found");
1137 Ok(())
1138 }
1139
1140 #[test]
1141 fn debug_shows_status_and_inner() {
1142 let err = AutumnError::bad_request(TestError("oops".into()));
1143 let debug = format!("{err:?}");
1144 assert!(debug.contains("AutumnError"));
1145 assert!(debug.contains("400"));
1146 }
1147
1148 #[tokio::test]
1149 async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
1150 let err = AutumnError::unprocessable_msg("title required");
1151 let response = err.into_response();
1152
1153 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1154 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1155 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1156 assert_eq!(json["status"], 422);
1157 assert_eq!(json["detail"], "title required");
1158 assert_eq!(json["code"], "autumn.unprocessable_entity");
1159 Ok(())
1160 }
1161
1162 #[tokio::test]
1163 async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
1164 let err = AutumnError::service_unavailable_msg("db down");
1165 let response = err.into_response();
1166
1167 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1168 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1169 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1170 assert_eq!(json["status"], 503);
1171 assert_eq!(json["detail"], "db down");
1172 assert_eq!(json["code"], "autumn.service_unavailable");
1173 Ok(())
1174 }
1175
1176 #[test]
1177 fn conflict_is_409() {
1178 let err = AutumnError::conflict(TestError("stale version".into()));
1179 assert_eq!(err.status(), StatusCode::CONFLICT);
1180 }
1181
1182 #[test]
1183 fn conflict_msg_is_409() {
1184 let err = AutumnError::conflict_msg("please reload and retry");
1185 assert_eq!(err.status(), StatusCode::CONFLICT);
1186 assert_eq!(err.to_string(), "please reload and retry");
1187 }
1188
1189 #[test]
1190 fn gone_is_410() {
1191 let err = AutumnError::gone(TestError("sunsetted".into()));
1192 assert_eq!(err.status(), StatusCode::GONE);
1193 }
1194
1195 #[test]
1196 fn gone_msg_is_410() {
1197 let err = AutumnError::gone_msg("API version has been sunsetted");
1198 assert_eq!(err.status(), StatusCode::GONE);
1199 assert_eq!(err.to_string(), "API version has been sunsetted");
1200 }
1201
1202 #[tokio::test]
1203 async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
1204 let err = AutumnError::conflict_msg("version mismatch");
1205 let response = err.into_response();
1206
1207 assert_eq!(response.status(), StatusCode::CONFLICT);
1208 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1209 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1210 assert_eq!(json["status"], 409);
1211 assert_eq!(json["detail"], "version mismatch");
1212 assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
1213 assert_eq!(json["title"], "Conflict");
1214 Ok(())
1215 }
1216
1217 #[tokio::test]
1218 async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
1219 let err = AutumnError::conflict_msg("version mismatch");
1220 let response = err.into_response();
1221
1222 assert_eq!(response.status(), StatusCode::CONFLICT);
1223 let hx_trigger = response
1224 .headers()
1225 .get("HX-Trigger")
1226 .expect("HX-Trigger header present");
1227 assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
1228 Ok(())
1229 }
1230}