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 if matches!(
189 any_err.downcast_ref::<crate::lock::LockError>(),
190 Some(
191 crate::lock::LockError::PoolUnavailable(_) | crate::lock::LockError::Timeout { .. }
192 )
193 ) {
194 status = StatusCode::SERVICE_UNAVAILABLE;
195 }
196
197 #[cfg(feature = "mail")]
198 {
199 if let Some(crate::mail::MailError::RuntimeUnavailable(msg)) =
200 any_err.downcast_ref::<crate::mail::MailError>()
201 && msg.contains("circuit breaker is open")
202 {
203 status = StatusCode::SERVICE_UNAVAILABLE;
204 }
205 }
206
207 if any_err
210 .downcast_ref::<crate::tenant_cell::QuotaExceeded>()
211 .is_some()
212 {
213 status = StatusCode::SERVICE_UNAVAILABLE;
214 }
215
216 Self {
217 inner: Box::new(err),
218 status,
219 details: None,
220 problem_type: None,
221 cache_idempotency_response: false,
222 #[cfg(debug_assertions)]
223 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
224 }
225 }
226}
227
228impl AutumnError {
229 #[must_use]
242 pub const fn with_status(mut self, status: StatusCode) -> Self {
243 self.status = status;
244 self
245 }
246
247 pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
259 Self {
260 inner: Box::new(err),
261 status: StatusCode::INTERNAL_SERVER_ERROR,
262 details: None,
263 problem_type: None,
264 cache_idempotency_response: false,
265 #[cfg(debug_assertions)]
266 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
267 }
268 }
269
270 pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
282 Self {
283 inner: Box::new(err),
284 status: StatusCode::NOT_FOUND,
285 details: None,
286 problem_type: None,
287 cache_idempotency_response: false,
288 #[cfg(debug_assertions)]
289 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
290 }
291 }
292
293 pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
305 Self {
306 inner: Box::new(err),
307 status: StatusCode::BAD_REQUEST,
308 details: None,
309 problem_type: None,
310 cache_idempotency_response: false,
311 #[cfg(debug_assertions)]
312 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
313 }
314 }
315
316 pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
331 Self {
332 inner: Box::new(err),
333 status: StatusCode::UNPROCESSABLE_ENTITY,
334 details: None,
335 problem_type: None,
336 cache_idempotency_response: false,
337 #[cfg(debug_assertions)]
338 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
339 }
340 }
341
342 pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
354 Self {
355 inner: Box::new(err),
356 status: StatusCode::SERVICE_UNAVAILABLE,
357 details: None,
358 problem_type: None,
359 cache_idempotency_response: false,
360 #[cfg(debug_assertions)]
361 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
362 }
363 }
364
365 pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
377 Self {
378 inner: Box::new(err),
379 status: StatusCode::UNAUTHORIZED,
380 details: None,
381 problem_type: None,
382 cache_idempotency_response: false,
383 #[cfg(debug_assertions)]
384 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
385 }
386 }
387
388 pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
400 Self {
401 inner: Box::new(err),
402 status: StatusCode::FORBIDDEN,
403 details: None,
404 problem_type: None,
405 cache_idempotency_response: false,
406 #[cfg(debug_assertions)]
407 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
408 }
409 }
410
411 #[must_use]
433 pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
434 Self {
435 inner: Box::new(StringError("Validation failed".into())),
436 status: StatusCode::UNPROCESSABLE_ENTITY,
437 details: Some(details),
438 problem_type: None,
439 cache_idempotency_response: false,
440 #[cfg(debug_assertions)]
441 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
442 }
443 }
444
445 pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
459 Self::internal_server_error(StringError(msg.into()))
460 }
461
462 pub fn not_found_msg(msg: impl Into<String>) -> Self {
475 Self::not_found(StringError(msg.into()))
476 }
477
478 pub fn bad_request_msg(msg: impl Into<String>) -> Self {
490 Self::bad_request(StringError(msg.into()))
491 }
492
493 pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
505 Self::unprocessable(StringError(msg.into()))
506 }
507
508 pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
520 Self::unauthorized(StringError(msg.into()))
521 }
522
523 pub fn forbidden_msg(msg: impl Into<String>) -> Self {
535 Self::forbidden(StringError(msg.into()))
536 }
537
538 pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
550 Self::service_unavailable(StringError(msg.into()))
551 }
552
553 pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
568 Self {
569 inner: Box::new(err),
570 status: StatusCode::CONFLICT,
571 details: None,
572 problem_type: Some("https://autumn.dev/problems/conflict"),
573 cache_idempotency_response: false,
574 #[cfg(debug_assertions)]
575 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
576 }
577 }
578
579 pub fn conflict_msg(msg: impl Into<String>) -> Self {
591 Self::conflict(StringError(msg.into()))
592 }
593
594 pub fn gone(err: impl std::error::Error + Send + Sync + 'static) -> Self {
606 Self {
607 inner: Box::new(err),
608 status: StatusCode::GONE,
609 details: None,
610 problem_type: Some("https://autumn.dev/problems/gone"),
611 cache_idempotency_response: false,
612 #[cfg(debug_assertions)]
613 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
614 }
615 }
616
617 pub fn gone_msg(msg: impl Into<String>) -> Self {
629 Self::gone(StringError(msg.into()))
630 }
631
632 pub fn query_timeout(msg: impl Into<String>) -> Self {
649 Self {
650 inner: Box::new(StringError(msg.into())),
651 status: StatusCode::SERVICE_UNAVAILABLE,
652 details: None,
653 problem_type: Some("https://autumn.dev/problems/query-timeout"),
654 cache_idempotency_response: false,
655 #[cfg(debug_assertions)]
656 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
657 }
658 }
659
660 #[must_use]
672 pub const fn status(&self) -> StatusCode {
673 self.status
674 }
675
676 #[doc(hidden)]
677 #[must_use]
678 pub(crate) const fn cache_idempotency_response(mut self) -> Self {
679 self.cache_idempotency_response = true;
680 self
681 }
682
683 #[must_use]
688 pub fn source_chain(&self) -> Vec<String> {
689 let mut chain = Vec::new();
690 let mut source = self.inner.source();
691 while let Some(error) = source {
692 chain.push(error.to_string());
693 source = error.source();
694 }
695 chain
696 }
697
698 #[must_use]
700 pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
701 let err: &(dyn std::error::Error + 'static) = self.inner.as_ref();
702 err.downcast_ref::<T>()
703 }
704
705 #[must_use]
713 pub fn downcast_chain_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
714 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self.inner.as_ref());
715 while let Some(err) = current {
716 if let Some(t) = err.downcast_ref::<T>() {
717 return Some(t);
718 }
719 current = err.source();
720 }
721 None
722 }
723}
724
725#[cfg(feature = "db")]
757#[must_use]
758pub fn unique_violation_field<'a>(
759 err: &AutumnError,
760 mapping: &'a [(&str, &str, &str)],
761) -> Option<(&'a str, &'a str)> {
762 let diesel::result::Error::DatabaseError(
763 diesel::result::DatabaseErrorKind::UniqueViolation,
764 info,
765 ) = err.downcast_ref::<diesel::result::Error>()?
766 else {
767 return None;
768 };
769 let constraint = info.constraint_name()?;
770 mapping
771 .iter()
772 .find(|(c, _, _)| *c == constraint)
773 .map(|(_, field, message)| (*field, *message))
774}
775
776impl std::fmt::Display for AutumnError {
777 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
778 write!(f, "{}", self.inner)
779 }
780}
781
782impl std::fmt::Debug for AutumnError {
783 #[allow(clippy::missing_fields_in_debug)]
784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785 f.debug_struct("AutumnError")
786 .field("status", &self.status)
787 .field("inner", &self.inner)
788 .field("details", &self.details)
789 .field("problem_type", &self.problem_type)
790 .field(
791 "cache_idempotency_response",
792 &self.cache_idempotency_response,
793 )
794 .finish_non_exhaustive()
795 }
796}
797
798impl ProblemDetails {
799 #[must_use]
801 pub fn new(
802 status: StatusCode,
803 detail: impl Into<String>,
804 details: Option<&std::collections::HashMap<String, Vec<String>>>,
805 ) -> Self {
806 problem_details(status, detail.into(), details, None, None, None, true)
807 }
808}
809
810#[must_use]
812pub(crate) fn problem_details(
813 status: StatusCode,
814 detail: String,
815 details: Option<&std::collections::HashMap<String, Vec<String>>>,
816 explicit_type: Option<&'static str>,
817 request_id: Option<String>,
818 instance: Option<String>,
819 expose_internal_detail: bool,
820) -> ProblemDetails {
821 let has_validation_errors = details.is_some_and(|map| !map.is_empty());
822 let safe_detail = if status.is_server_error() && !expose_internal_detail {
823 server_error_detail(status)
824 } else {
825 detail
826 };
827
828 let code = explicit_type.map_or_else(
835 || problem_code_for(status, has_validation_errors).to_owned(),
836 |etype| {
837 let slug = etype.rsplit('/').next().unwrap_or(etype);
838 format!("autumn.{}", slug.replace('-', "_"))
839 },
840 );
841
842 ProblemDetails {
843 type_uri: explicit_type
844 .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
845 .to_owned(),
846 title: problem_title_for(status, has_validation_errors).to_owned(),
847 status: status.as_u16(),
848 detail: safe_detail,
849 instance,
850 code,
851 request_id,
852 errors: validation_errors(details),
853 }
854}
855
856#[must_use]
859pub(crate) fn problem_details_json_string(
860 status: StatusCode,
861 detail: impl Into<String>,
862 details: Option<&std::collections::HashMap<String, Vec<String>>>,
863 explicit_type: Option<&'static str>,
864 request_id: Option<String>,
865 instance: Option<String>,
866 expose_internal_detail: bool,
867) -> String {
868 let problem = problem_details(
869 status,
870 detail.into(),
871 details,
872 explicit_type,
873 request_id,
874 instance,
875 expose_internal_detail,
876 );
877 problem_details_to_json_string(&problem)
878}
879
880#[must_use]
882pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
883 serde_json::to_string(&problem).unwrap_or_else(|_| {
884 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()
885 })
886}
887
888fn validation_errors(
889 details: Option<&std::collections::HashMap<String, Vec<String>>>,
890) -> Vec<ProblemFieldError> {
891 let mut errors: Vec<_> = details
892 .into_iter()
893 .flat_map(std::collections::HashMap::iter)
894 .map(|(field, messages)| ProblemFieldError {
895 field: field.clone(),
896 messages: messages.clone(),
897 })
898 .collect();
899 errors.sort_by(|left, right| left.field.cmp(&right.field));
900 errors
901}
902
903const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
904 if has_validation_errors {
905 return "https://autumn.dev/problems/validation-failed";
906 }
907
908 match status {
909 StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
910 StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
911 StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
912 StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
913 StatusCode::GONE => "https://autumn.dev/problems/gone",
914 StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
915 StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
916 StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
917 StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
918 StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
919 StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
920 _ => "about:blank",
921 }
922}
923
924fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
925 if has_validation_errors {
926 return "Validation Failed";
927 }
928
929 match status {
930 StatusCode::BAD_REQUEST => "Bad Request",
931 StatusCode::UNAUTHORIZED => "Unauthorized",
932 StatusCode::FORBIDDEN => "Forbidden",
933 StatusCode::NOT_FOUND => "Not Found",
934 StatusCode::GONE => "Gone",
935 StatusCode::CONFLICT => "Conflict",
936 StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
937 StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
938 StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
939 StatusCode::NOT_IMPLEMENTED => "Not Implemented",
940 StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
941 _ => status.canonical_reason().unwrap_or("Error"),
942 }
943}
944
945fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
946 if has_validation_errors {
947 return "autumn.validation_failed";
948 }
949
950 match status {
951 StatusCode::BAD_REQUEST => "autumn.bad_request",
952 StatusCode::UNAUTHORIZED => "autumn.unauthorized",
953 StatusCode::FORBIDDEN => "autumn.forbidden",
954 StatusCode::NOT_FOUND => "autumn.not_found",
955 StatusCode::GONE => "autumn.gone",
956 StatusCode::CONFLICT => "autumn.conflict",
957 StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
958 StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
959 StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
960 StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
961 StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
962 _ if status.is_client_error() => "autumn.client_error",
963 _ if status.is_server_error() => "autumn.server_error",
964 _ => "autumn.error",
965 }
966}
967
968fn server_error_detail(status: StatusCode) -> String {
969 match status {
970 StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
971 StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
972 _ => "Internal server error".to_owned(),
973 }
974}
975
976impl IntoResponse for AutumnError {
977 fn into_response(self) -> Response {
978 let mut status = self.status;
979 let message = self.inner.to_string();
980 let mut problem_type = self.problem_type;
981
982 let err_str = message.to_lowercase();
984 if err_str.contains("57014")
985 || err_str.contains("query_canceled")
986 || err_str.contains("canceling statement due to statement timeout")
987 || err_str.contains("statement timeout")
988 || err_str.contains("query canceled")
989 {
990 status = StatusCode::SERVICE_UNAVAILABLE;
991 problem_type = Some("https://autumn.dev/problems/query-timeout");
992 }
993
994 let details = self.details.clone();
995 let cache_idempotency_response = self.cache_idempotency_response;
996
997 let error_info = crate::middleware::AutumnErrorInfo {
1000 status,
1001 message: message.clone(),
1002 details: details.clone(),
1003 problem_type,
1004 #[cfg(debug_assertions)]
1005 backtrace_string: self.backtrace_string.clone(),
1006 #[cfg(not(debug_assertions))]
1007 backtrace_string: None,
1008 };
1009
1010 let body = problem_details(
1011 status,
1012 message,
1013 details.as_ref(),
1014 problem_type,
1015 None,
1016 None,
1017 true,
1018 );
1019 let mut response = (status, axum::Json(body)).into_response();
1020 response.headers_mut().insert(
1021 header::CONTENT_TYPE,
1022 HeaderValue::from_static("application/problem+json"),
1023 );
1024 if status == StatusCode::CONFLICT {
1025 response.headers_mut().insert(
1026 "HX-Trigger",
1027 HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
1028 );
1029 }
1030 if cache_idempotency_response {
1031 response
1032 .extensions_mut()
1033 .insert(crate::idempotency::IdempotencyCacheCommittedErrorResponse);
1034 }
1035 response.extensions_mut().insert(error_info);
1036 response
1037 }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043 use axum::http::StatusCode;
1044
1045 #[derive(Debug)]
1046 struct TestError(String);
1047
1048 impl std::fmt::Display for TestError {
1049 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1050 write!(f, "{}", self.0)
1051 }
1052 }
1053
1054 impl std::error::Error for TestError {}
1055
1056 #[derive(Debug)]
1057 struct WrappedError {
1058 message: String,
1059 source: TestError,
1060 }
1061
1062 impl std::fmt::Display for WrappedError {
1063 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1064 write!(f, "{}", self.message)
1065 }
1066 }
1067
1068 impl std::error::Error for WrappedError {
1069 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1070 Some(&self.source)
1071 }
1072 }
1073
1074 #[test]
1075 fn blanket_from_defaults_to_500() {
1076 let err: AutumnError = TestError("boom".into()).into();
1077 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1078 }
1079
1080 #[test]
1081 fn internal_server_error_is_500() {
1082 let err = AutumnError::internal_server_error(TestError("boom".into()));
1083 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1084 }
1085
1086 #[test]
1087 fn test_not_found_error() {
1088 let err = AutumnError::not_found(std::io::Error::other("no such user"));
1089 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1090 }
1091
1092 #[test]
1093 fn not_found_is_404() {
1094 let err = AutumnError::not_found(TestError("missing".into()));
1095 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1096 }
1097
1098 #[test]
1099 fn bad_request_is_400() {
1100 let err = AutumnError::bad_request(TestError("invalid input".into()));
1101 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1102 }
1103
1104 #[test]
1105 fn unprocessable_is_422() {
1106 let err = AutumnError::unprocessable(TestError("bad entity".into()));
1107 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1108 }
1109
1110 #[test]
1111 fn unauthorized_is_401() {
1112 let err = AutumnError::unauthorized(TestError("unauthorized".into()));
1113 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1114 }
1115
1116 #[test]
1117 fn forbidden_is_403() {
1118 let err = AutumnError::forbidden(TestError("forbidden".into()));
1119 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1120 }
1121
1122 #[test]
1123 fn validation_is_422() {
1124 let mut details = std::collections::HashMap::new();
1125 details.insert("field".to_string(), vec!["error".to_string()]);
1126 let err = AutumnError::validation(details);
1127 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1128 }
1129
1130 #[test]
1131 fn service_unavailable_is_503() {
1132 let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
1133 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1134 }
1135
1136 #[test]
1137 fn internal_server_error_msg_is_500() {
1138 let err = AutumnError::internal_server_error_msg("db failure");
1139 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1140 assert_eq!(err.to_string(), "db failure");
1141 }
1142
1143 #[test]
1144 fn not_found_msg_is_404() {
1145 let err = AutumnError::not_found_msg("no such user");
1146 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1147 assert_eq!(err.to_string(), "no such user");
1148 }
1149
1150 #[test]
1151 fn bad_request_msg_is_400() {
1152 let err = AutumnError::bad_request_msg("invalid input");
1153 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1154 }
1155
1156 #[test]
1157 fn unprocessable_msg_is_422() {
1158 let err = AutumnError::unprocessable_msg("title required");
1159 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1160 }
1161
1162 #[test]
1163 fn unauthorized_msg_is_401() {
1164 let err = AutumnError::unauthorized_msg("login required");
1165 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1166 }
1167
1168 #[test]
1169 fn forbidden_msg_is_403() {
1170 let err = AutumnError::forbidden_msg("no access");
1171 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1172 }
1173
1174 #[test]
1175 fn service_unavailable_msg_is_503() {
1176 let err = AutumnError::service_unavailable_msg("db down");
1177 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1178 assert_eq!(err.to_string(), "db down");
1179 }
1180
1181 #[test]
1182 fn with_status_overrides() {
1183 let err: AutumnError = TestError("forbidden".into()).into();
1184 let err = err.with_status(StatusCode::FORBIDDEN);
1185 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1186 }
1187
1188 #[test]
1189 fn display_uses_inner_message() {
1190 let err: AutumnError = TestError("something broke".into()).into();
1191 assert_eq!(err.to_string(), "something broke");
1192 }
1193
1194 #[test]
1195 fn source_chain_lists_inner_sources() {
1196 let err = AutumnError::internal_server_error(WrappedError {
1197 message: "failed to backfill".to_string(),
1198 source: TestError("database connection dropped".to_string()),
1199 });
1200
1201 assert_eq!(
1202 err.source_chain(),
1203 vec!["database connection dropped".to_string()]
1204 );
1205 }
1206
1207 #[test]
1208 fn into_response_has_correct_status() {
1209 let err = AutumnError::not_found(TestError("not found".into()));
1210 let response = err.into_response();
1211 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1212 }
1213
1214 #[tokio::test]
1215 async fn into_response_has_json_body() -> Result<(), axum::Error> {
1216 let err = AutumnError::not_found(TestError("not found".into()));
1217 let response = err.into_response();
1218
1219 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1220 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1221
1222 assert_eq!(json["status"], 404);
1223 assert_eq!(json["detail"], "not found");
1224 assert_eq!(json["code"], "autumn.not_found");
1225 Ok(())
1226 }
1227
1228 #[test]
1229 fn debug_shows_status_and_inner() {
1230 let err = AutumnError::bad_request(TestError("oops".into()));
1231 let debug = format!("{err:?}");
1232 assert!(debug.contains("AutumnError"));
1233 assert!(debug.contains("400"));
1234 }
1235
1236 #[tokio::test]
1237 async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
1238 let err = AutumnError::unprocessable_msg("title required");
1239 let response = err.into_response();
1240
1241 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1242 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1243 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1244 assert_eq!(json["status"], 422);
1245 assert_eq!(json["detail"], "title required");
1246 assert_eq!(json["code"], "autumn.unprocessable_entity");
1247 Ok(())
1248 }
1249
1250 #[tokio::test]
1251 async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
1252 let err = AutumnError::service_unavailable_msg("db down");
1253 let response = err.into_response();
1254
1255 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1256 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1257 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1258 assert_eq!(json["status"], 503);
1259 assert_eq!(json["detail"], "db down");
1260 assert_eq!(json["code"], "autumn.service_unavailable");
1261 Ok(())
1262 }
1263
1264 #[test]
1265 fn conflict_is_409() {
1266 let err = AutumnError::conflict(TestError("stale version".into()));
1267 assert_eq!(err.status(), StatusCode::CONFLICT);
1268 }
1269
1270 #[test]
1271 fn conflict_msg_is_409() {
1272 let err = AutumnError::conflict_msg("please reload and retry");
1273 assert_eq!(err.status(), StatusCode::CONFLICT);
1274 assert_eq!(err.to_string(), "please reload and retry");
1275 }
1276
1277 #[test]
1278 fn gone_is_410() {
1279 let err = AutumnError::gone(TestError("sunsetted".into()));
1280 assert_eq!(err.status(), StatusCode::GONE);
1281 }
1282
1283 #[test]
1284 fn gone_msg_is_410() {
1285 let err = AutumnError::gone_msg("API version has been sunsetted");
1286 assert_eq!(err.status(), StatusCode::GONE);
1287 assert_eq!(err.to_string(), "API version has been sunsetted");
1288 }
1289
1290 #[tokio::test]
1291 async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
1292 let err = AutumnError::conflict_msg("version mismatch");
1293 let response = err.into_response();
1294
1295 assert_eq!(response.status(), StatusCode::CONFLICT);
1296 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1297 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1298 assert_eq!(json["status"], 409);
1299 assert_eq!(json["detail"], "version mismatch");
1300 assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
1301 assert_eq!(json["title"], "Conflict");
1302 Ok(())
1303 }
1304
1305 #[tokio::test]
1306 async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
1307 let err = AutumnError::conflict_msg("version mismatch");
1308 let response = err.into_response();
1309
1310 assert_eq!(response.status(), StatusCode::CONFLICT);
1311 let hx_trigger = response
1312 .headers()
1313 .get("HX-Trigger")
1314 .expect("HX-Trigger header present");
1315 assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
1316 Ok(())
1317 }
1318
1319 #[cfg(feature = "db")]
1322 mod unique_violation_field_tests {
1323 use super::*;
1324 use crate::error::unique_violation_field;
1325
1326 #[derive(Debug)]
1327 struct FakeDbErrorInfo {
1328 constraint: Option<&'static str>,
1329 }
1330
1331 impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
1332 fn message(&self) -> &'static str {
1333 "duplicate key value violates unique constraint"
1334 }
1335 fn details(&self) -> Option<&str> {
1336 None
1337 }
1338 fn hint(&self) -> Option<&str> {
1339 None
1340 }
1341 fn table_name(&self) -> Option<&str> {
1342 None
1343 }
1344 fn column_name(&self) -> Option<&str> {
1345 None
1346 }
1347 fn constraint_name(&self) -> Option<&str> {
1348 self.constraint
1349 }
1350 fn statement_position(&self) -> Option<i32> {
1351 None
1352 }
1353 }
1354
1355 fn unique_violation(constraint: Option<&'static str>) -> diesel::result::Error {
1356 diesel::result::Error::DatabaseError(
1357 diesel::result::DatabaseErrorKind::UniqueViolation,
1358 Box::new(FakeDbErrorInfo { constraint }),
1359 )
1360 }
1361
1362 const MAPPING: &[(&str, &str, &str)] =
1363 &[("idx_users_email_unique", "email", "has already been taken")];
1364
1365 #[test]
1366 fn matches_constraint_name_to_field_and_message() {
1367 let err: AutumnError = AutumnError::internal_server_error(unique_violation(Some(
1368 "idx_users_email_unique",
1369 )));
1370 assert_eq!(
1371 unique_violation_field(&err, MAPPING),
1372 Some(("email", "has already been taken"))
1373 );
1374 }
1375
1376 #[test]
1377 fn returns_none_for_unrecognized_constraint() {
1378 let err: AutumnError =
1379 AutumnError::internal_server_error(unique_violation(Some("some_other_constraint")));
1380 assert_eq!(unique_violation_field(&err, MAPPING), None);
1381 }
1382
1383 #[test]
1384 fn returns_none_when_constraint_name_is_absent() {
1385 let err: AutumnError = AutumnError::internal_server_error(unique_violation(None));
1386 assert_eq!(unique_violation_field(&err, MAPPING), None);
1387 }
1388
1389 #[test]
1390 fn returns_none_for_non_unique_violation_db_errors() {
1391 let err: AutumnError =
1392 AutumnError::internal_server_error(diesel::result::Error::NotFound);
1393 assert_eq!(unique_violation_field(&err, MAPPING), None);
1394 }
1395
1396 #[test]
1397 fn returns_none_for_non_db_errors() {
1398 let err = AutumnError::internal_server_error_msg("plain string error");
1399 assert_eq!(unique_violation_field(&err, MAPPING), None);
1400 }
1401
1402 #[test]
1403 fn recovers_diesel_error_through_repository_style_blanket_from() {
1404 fn insert() -> Result<(), diesel::result::Error> {
1409 Err(unique_violation(Some("idx_users_email_unique")))
1410 }
1411 fn handler() -> Result<(), AutumnError> {
1412 insert()?;
1413 Ok(())
1414 }
1415 let err = handler().unwrap_err();
1416 assert_eq!(
1417 unique_violation_field(&err, MAPPING),
1418 Some(("email", "has already been taken"))
1419 );
1420 }
1421 }
1422}