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 {
153 inner: Box<dyn std::error::Error + Send + Sync>,
154 status: StatusCode,
155 details: Option<std::collections::HashMap<String, Vec<String>>>,
156 problem_type: Option<&'static str>,
157 cache_idempotency_response: bool,
158 #[cfg(debug_assertions)]
161 pub(crate) backtrace_string: Option<String>,
162}
163
164pub type AutumnResult<T> = Result<T, AutumnError>;
180
181impl<E> From<E> for AutumnError
182where
183 E: std::error::Error + Send + Sync + 'static,
184{
185 fn from(err: E) -> Self {
186 #[cfg(feature = "db")]
199 if error_chain_is_pg_nul_rejection(&err) {
200 return Self {
201 inner: Box::new(NulByteRejected(err)),
202 status: StatusCode::UNPROCESSABLE_ENTITY,
203 details: None,
204 problem_type: None,
205 cache_idempotency_response: false,
206 #[cfg(debug_assertions)]
207 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
208 };
209 }
210
211 let mut status = StatusCode::INTERNAL_SERVER_ERROR;
212 let any_err: &dyn std::any::Any = &err;
213
214 if std::any::type_name::<E>().contains("CircuitBreakerError")
215 && err.to_string() == "circuit breaker is open"
216 {
217 status = StatusCode::SERVICE_UNAVAILABLE;
218 }
219
220 #[cfg(feature = "http-client")]
221 {
222 if matches!(
223 any_err.downcast_ref::<crate::http_client::ClientError>(),
224 Some(
225 crate::http_client::ClientError::CircuitBreakerOpen
226 | crate::http_client::ClientError::ThrottledLocally { .. }
227 | crate::http_client::ClientError::FaultInjected(_)
228 )
229 ) {
230 status = StatusCode::SERVICE_UNAVAILABLE;
231 }
232 }
233
234 #[cfg(feature = "http-client")]
238 if let Some(wire_err) = any_err.downcast_ref::<crate::wire::WireError>() {
239 status = wire_err.status();
240 }
241
242 if let Some(push_err) = any_err.downcast_ref::<crate::push::PushError>() {
248 status = push_err.status();
249 }
250
251 #[cfg(feature = "constela")]
258 if let Some(constela_err) = any_err.downcast_ref::<crate::constela::ConstelaError>() {
259 status = constela_err.http_status();
260 }
261
262 #[cfg(feature = "sqlite")]
272 if let Some(fleet_err) = any_err.downcast_ref::<crate::db::fleet::FleetError>() {
273 status = fleet_err.http_status();
274 }
275
276 if let Some(money_err) = any_err.downcast_ref::<crate::money::MoneyError>() {
277 status = money_err.http_status();
278 }
279 #[cfg(feature = "db")]
280 if let Some(ledger_err) = any_err.downcast_ref::<crate::money::ledger::LedgerError>() {
281 status = ledger_err.http_status();
282 }
283
284 if let Some(form_err) = any_err.downcast_ref::<crate::form::FormDecodeError>() {
287 status = form_err.status();
288 }
289
290 if matches!(
291 any_err.downcast_ref::<crate::lock::LockError>(),
292 Some(
293 crate::lock::LockError::PoolUnavailable(_)
294 | crate::lock::LockError::Timeout { .. }
295 | crate::lock::LockError::LeaseLost { .. }
296 )
297 ) {
298 status = StatusCode::SERVICE_UNAVAILABLE;
299 }
300
301 #[cfg(feature = "mail")]
302 {
303 if let Some(crate::mail::MailError::RuntimeUnavailable(msg)) =
304 any_err.downcast_ref::<crate::mail::MailError>()
305 && msg.contains("circuit breaker is open")
306 {
307 status = StatusCode::SERVICE_UNAVAILABLE;
308 }
309 }
310
311 if any_err
314 .downcast_ref::<crate::tenant_cell::QuotaExceeded>()
315 .is_some()
316 || any_err
317 .downcast_ref::<crate::tenant_cell::TenantAllocationError>()
318 .is_some()
319 {
320 status = StatusCode::SERVICE_UNAVAILABLE;
321 }
322
323 if chain_has_deadline_stop(&err, false) {
328 status = StatusCode::GATEWAY_TIMEOUT;
329 }
330
331 Self {
332 inner: Box::new(err),
333 status,
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
343impl AutumnError {
344 #[must_use]
357 pub const fn with_status(mut self, status: StatusCode) -> Self {
358 self.status = status;
359 self
360 }
361
362 pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
374 Self {
375 inner: Box::new(err),
376 status: StatusCode::INTERNAL_SERVER_ERROR,
377 details: None,
378 problem_type: None,
379 cache_idempotency_response: false,
380 #[cfg(debug_assertions)]
381 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
382 }
383 }
384
385 pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
397 Self {
398 inner: Box::new(err),
399 status: StatusCode::NOT_FOUND,
400 details: None,
401 problem_type: None,
402 cache_idempotency_response: false,
403 #[cfg(debug_assertions)]
404 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
405 }
406 }
407
408 pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
420 Self {
421 inner: Box::new(err),
422 status: StatusCode::BAD_REQUEST,
423 details: None,
424 problem_type: None,
425 cache_idempotency_response: false,
426 #[cfg(debug_assertions)]
427 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
428 }
429 }
430
431 pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
446 Self {
447 inner: Box::new(err),
448 status: StatusCode::UNPROCESSABLE_ENTITY,
449 details: None,
450 problem_type: None,
451 cache_idempotency_response: false,
452 #[cfg(debug_assertions)]
453 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
454 }
455 }
456
457 pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
469 Self {
470 inner: Box::new(err),
471 status: StatusCode::SERVICE_UNAVAILABLE,
472 details: None,
473 problem_type: None,
474 cache_idempotency_response: false,
475 #[cfg(debug_assertions)]
476 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
477 }
478 }
479
480 pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
492 Self {
493 inner: Box::new(err),
494 status: StatusCode::UNAUTHORIZED,
495 details: None,
496 problem_type: None,
497 cache_idempotency_response: false,
498 #[cfg(debug_assertions)]
499 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
500 }
501 }
502
503 pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
515 Self {
516 inner: Box::new(err),
517 status: StatusCode::FORBIDDEN,
518 details: None,
519 problem_type: None,
520 cache_idempotency_response: false,
521 #[cfg(debug_assertions)]
522 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
523 }
524 }
525
526 #[must_use]
548 pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
549 Self {
550 inner: Box::new(StringError("Validation failed".into())),
551 status: StatusCode::UNPROCESSABLE_ENTITY,
552 details: Some(details),
553 problem_type: None,
554 cache_idempotency_response: false,
555 #[cfg(debug_assertions)]
556 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
557 }
558 }
559
560 pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
574 Self::internal_server_error(StringError(msg.into()))
575 }
576
577 pub fn not_found_msg(msg: impl Into<String>) -> Self {
590 Self::not_found(StringError(msg.into()))
591 }
592
593 pub fn bad_request_msg(msg: impl Into<String>) -> Self {
605 Self::bad_request(StringError(msg.into()))
606 }
607
608 pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
620 Self::unprocessable(StringError(msg.into()))
621 }
622
623 pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
635 Self::unauthorized(StringError(msg.into()))
636 }
637
638 pub fn forbidden_msg(msg: impl Into<String>) -> Self {
650 Self::forbidden(StringError(msg.into()))
651 }
652
653 pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
665 Self::service_unavailable(StringError(msg.into()))
666 }
667
668 pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
683 Self {
684 inner: Box::new(err),
685 status: StatusCode::CONFLICT,
686 details: None,
687 problem_type: Some(PROBLEM_TYPE_CONFLICT),
688 cache_idempotency_response: false,
689 #[cfg(debug_assertions)]
690 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
691 }
692 }
693
694 pub fn conflict_msg(msg: impl Into<String>) -> Self {
706 Self::conflict(StringError(msg.into()))
707 }
708
709 pub fn gone(err: impl std::error::Error + Send + Sync + 'static) -> Self {
721 Self {
722 inner: Box::new(err),
723 status: StatusCode::GONE,
724 details: None,
725 problem_type: Some(PROBLEM_TYPE_GONE),
726 cache_idempotency_response: false,
727 #[cfg(debug_assertions)]
728 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
729 }
730 }
731
732 pub fn gone_msg(msg: impl Into<String>) -> Self {
744 Self::gone(StringError(msg.into()))
745 }
746
747 pub fn query_timeout(msg: impl Into<String>) -> Self {
764 Self {
765 inner: Box::new(StringError(msg.into())),
766 status: StatusCode::SERVICE_UNAVAILABLE,
767 details: None,
768 problem_type: Some(PROBLEM_TYPE_QUERY_TIMEOUT),
769 cache_idempotency_response: false,
770 #[cfg(debug_assertions)]
771 backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
772 }
773 }
774
775 #[must_use]
792 pub const fn status(&self) -> StatusCode {
793 self.status
794 }
795
796 #[must_use]
822 pub const fn details(&self) -> Option<&std::collections::HashMap<String, Vec<String>>> {
823 self.details.as_ref()
824 }
825
826 #[must_use]
855 pub fn message(&self) -> String {
856 self.inner.to_string()
857 }
858
859 #[must_use]
884 pub fn code(&self) -> std::borrow::Cow<'static, str> {
885 let (status, problem_type) = self.rendered_problem();
886 problem_code(status, self.has_field_errors(), problem_type)
887 }
888
889 fn has_field_errors(&self) -> bool {
894 self.details.as_ref().is_some_and(|map| !map.is_empty())
895 }
896
897 pub(crate) fn rendered_problem(&self) -> (StatusCode, Option<&'static str>) {
904 let lowered = self.inner.to_string().to_lowercase();
905 if lowered.contains("57014")
906 || lowered.contains("query_canceled")
907 || lowered.contains("canceling statement due to statement timeout")
908 || lowered.contains("statement timeout")
909 || lowered.contains("query canceled")
910 {
911 return (
912 StatusCode::SERVICE_UNAVAILABLE,
913 Some(PROBLEM_TYPE_QUERY_TIMEOUT),
914 );
915 }
916 (self.status, self.problem_type)
917 }
918
919 #[doc(hidden)]
920 #[must_use]
921 pub(crate) const fn cache_idempotency_response(mut self) -> Self {
922 self.cache_idempotency_response = true;
923 self
924 }
925
926 #[must_use]
931 pub fn source_chain(&self) -> Vec<String> {
932 let mut chain = Vec::new();
933 let mut source = self.inner.source();
934 while let Some(error) = source {
935 chain.push(error.to_string());
936 source = error.source();
937 }
938 chain
939 }
940
941 #[must_use]
943 pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
944 let err: &(dyn std::error::Error + 'static) = self.inner.as_ref();
945 err.downcast_ref::<T>()
946 }
947
948 #[must_use]
956 pub fn downcast_chain_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
957 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self.inner.as_ref());
958 while let Some(err) = current {
959 if let Some(t) = err.downcast_ref::<T>() {
960 return Some(t);
961 }
962 current = err.source();
963 }
964 None
965 }
966}
967
968#[cfg(feature = "db")]
1014#[must_use]
1015pub fn unique_violation_field<'a>(
1016 err: &AutumnError,
1017 mapping: &'a [(&str, &str, &str)],
1018) -> Option<(&'a str, &'a str)> {
1019 let diesel::result::Error::DatabaseError(
1020 diesel::result::DatabaseErrorKind::UniqueViolation,
1021 info,
1022 ) = err.downcast_ref::<diesel::result::Error>()?
1023 else {
1024 return None;
1025 };
1026 if let Some(constraint) = info.constraint_name() {
1027 return mapping
1028 .iter()
1029 .find(|(c, _, _)| *c == constraint)
1030 .map(|(_, field, message)| (*field, *message));
1031 }
1032 let violated_columns = sqlite_unique_violation_columns(info.message())?;
1033 mapping
1034 .iter()
1035 .find(|(_, field, _)| violated_columns.iter().any(|column| column == field))
1036 .map(|(_, field, message)| (*field, *message))
1037}
1038
1039#[cfg(feature = "db")]
1047fn sqlite_unique_violation_columns(message: &str) -> Option<Vec<&str>> {
1048 let columns = message.strip_prefix("UNIQUE constraint failed: ")?;
1049 Some(
1050 columns
1051 .split(", ")
1052 .map(|qualified| qualified.rsplit('.').next().unwrap_or(qualified))
1053 .collect(),
1054 )
1055}
1056
1057pub const NUL_BYTE_REJECTED_MESSAGE: &str =
1068 "The submitted text contains a NUL character (0x00), which cannot be stored.";
1069
1070#[cfg(feature = "db")]
1075#[derive(Debug)]
1076pub(crate) struct NulByteRejected<E>(E);
1077
1078#[cfg(feature = "db")]
1079impl<E> std::fmt::Display for NulByteRejected<E> {
1080 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1081 f.write_str(NUL_BYTE_REJECTED_MESSAGE)
1082 }
1083}
1084
1085#[cfg(feature = "db")]
1086impl<E: std::error::Error + 'static> std::error::Error for NulByteRejected<E> {
1087 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1088 Some(&self.0)
1089 }
1090}
1091
1092#[cfg(feature = "db")]
1121#[must_use]
1122pub fn is_nul_byte_violation(err: &AutumnError) -> bool {
1123 error_chain_is_pg_nul_rejection(err.inner.as_ref())
1124}
1125
1126#[cfg(feature = "db")]
1135pub(crate) fn error_chain_is_pg_nul_rejection(err: &(dyn std::error::Error + 'static)) -> bool {
1136 let mut current = Some(err);
1137 while let Some(cause) = current {
1138 if cause
1139 .downcast_ref::<diesel::result::Error>()
1140 .is_some_and(is_pg_nul_byte_error)
1141 {
1142 return true;
1143 }
1144 current = cause.source();
1145 }
1146 false
1147}
1148
1149#[cfg(feature = "db")]
1151fn is_pg_nul_byte_error(err: &diesel::result::Error) -> bool {
1152 let diesel::result::Error::DatabaseError(_, info) = err else {
1153 return false;
1154 };
1155 pg_message_is_nul_rejection(info.message()) || pg_error_is_jsonb_nul_rejection(info.as_ref())
1156}
1157
1158#[cfg(feature = "db")]
1182fn pg_message_is_nul_rejection(message: &str) -> bool {
1183 message.ends_with(": 0x00") && message.contains("UTF")
1184}
1185
1186#[cfg(feature = "db")]
1195fn pg_error_is_jsonb_nul_rejection(info: &dyn diesel::result::DatabaseErrorInformation) -> bool {
1196 info.message() == "unsupported Unicode escape sequence"
1197 && info.details() == Some(r"\u0000 cannot be converted to text.")
1198}
1199
1200#[cfg(feature = "db")]
1216#[must_use]
1217pub fn nul_byte_json_fields(body: &serde_json::Value) -> Vec<String> {
1218 fn walk(value: &serde_json::Value, path: &str, out: &mut Vec<String>) {
1219 match value {
1220 serde_json::Value::String(text) if text.contains('\0') => out.push(path.to_owned()),
1221 serde_json::Value::Array(items) => {
1222 for (index, item) in items.iter().enumerate() {
1223 walk(item, &format!("{path}[{index}]"), out);
1224 }
1225 }
1226 serde_json::Value::Object(map) => {
1227 for (key, item) in map {
1228 let child = if path.is_empty() {
1229 key.clone()
1230 } else {
1231 format!("{path}.{key}")
1232 };
1233 walk(item, &child, out);
1234 }
1235 }
1236 _ => {}
1237 }
1238 }
1239 let mut out = Vec::new();
1240 walk(body, "", &mut out);
1241 out
1242}
1243
1244#[cfg(feature = "db")]
1254#[doc(hidden)]
1255#[must_use]
1256pub fn __name_nul_fields(
1257 mut err: AutumnError,
1258 body: impl FnOnce() -> Option<serde_json::Value>,
1259) -> AutumnError {
1260 if err.details.is_some() || !is_nul_byte_violation(&err) {
1261 return err;
1262 }
1263 let fields = body().map(|body| nul_byte_json_fields(&body));
1264 if let Some(fields) = fields.filter(|fields| !fields.is_empty()) {
1265 err.details = Some(
1266 fields
1267 .into_iter()
1268 .map(|field| {
1269 (
1270 field,
1271 vec![crate::form::NUL_CHARACTER_FIELD_ERROR.to_owned()],
1272 )
1273 })
1274 .collect(),
1275 );
1276 }
1277 err
1278}
1279
1280#[cfg(feature = "db")]
1289#[doc(hidden)]
1290pub struct MaybeJsonBody<'a, T>(pub &'a T);
1291
1292#[cfg(feature = "db")]
1294#[doc(hidden)]
1295pub trait MaybeJsonBodyViaSerialize {
1296 fn autumn_json_body(&self) -> Option<serde_json::Value>;
1298}
1299
1300#[cfg(feature = "db")]
1301impl<T: serde::Serialize> MaybeJsonBodyViaSerialize for MaybeJsonBody<'_, T> {
1302 fn autumn_json_body(&self) -> Option<serde_json::Value> {
1303 serde_json::to_value(self.0).ok()
1304 }
1305}
1306
1307#[cfg(feature = "db")]
1309#[doc(hidden)]
1310pub trait MaybeJsonBodyFallback {
1311 fn autumn_json_body(&self) -> Option<serde_json::Value>;
1313}
1314
1315#[cfg(feature = "db")]
1316impl<T> MaybeJsonBodyFallback for &MaybeJsonBody<'_, T> {
1317 fn autumn_json_body(&self) -> Option<serde_json::Value> {
1318 None
1319 }
1320}
1321
1322impl std::fmt::Display for AutumnError {
1323 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1324 write!(f, "{}", self.inner)?;
1325
1326 let mut fields: Vec<_> = self
1331 .details
1332 .iter()
1333 .flatten()
1334 .filter(|(_, messages)| !messages.is_empty())
1335 .collect();
1336 fields.sort_by_key(|(left, _)| *left);
1337
1338 for (index, (field, messages)) in fields.into_iter().enumerate() {
1339 f.write_str(if index == 0 { ": " } else { "; " })?;
1340 write!(f, "{field}: ")?;
1341 for (position, message) in messages.iter().enumerate() {
1342 if position > 0 {
1343 f.write_str(", ")?;
1344 }
1345 f.write_str(message)?;
1346 }
1347 }
1348 Ok(())
1349 }
1350}
1351
1352impl std::fmt::Debug for AutumnError {
1353 #[allow(clippy::missing_fields_in_debug)]
1354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1355 f.debug_struct("AutumnError")
1356 .field("status", &self.status)
1357 .field("inner", &self.inner)
1358 .field("details", &self.details)
1359 .field("problem_type", &self.problem_type)
1360 .field(
1361 "cache_idempotency_response",
1362 &self.cache_idempotency_response,
1363 )
1364 .finish_non_exhaustive()
1365 }
1366}
1367
1368impl ProblemDetails {
1369 #[must_use]
1371 pub fn new(
1372 status: StatusCode,
1373 detail: impl Into<String>,
1374 details: Option<&std::collections::HashMap<String, Vec<String>>>,
1375 ) -> Self {
1376 problem_details(status, detail.into(), details, None, None, None, true)
1377 }
1378}
1379
1380#[must_use]
1382pub(crate) fn problem_details(
1383 status: StatusCode,
1384 detail: String,
1385 details: Option<&std::collections::HashMap<String, Vec<String>>>,
1386 explicit_type: Option<&'static str>,
1387 request_id: Option<String>,
1388 instance: Option<String>,
1389 expose_internal_detail: bool,
1390) -> ProblemDetails {
1391 let has_validation_errors = details.is_some_and(|map| !map.is_empty());
1392 let safe_detail = if status.is_server_error() && !expose_internal_detail {
1393 server_error_detail(status)
1394 } else {
1395 detail
1396 };
1397
1398 let code = problem_code(status, has_validation_errors, explicit_type).into_owned();
1399
1400 ProblemDetails {
1401 type_uri: explicit_type
1402 .unwrap_or_else(|| problem_type_for(status, has_validation_errors))
1403 .to_owned(),
1404 title: problem_title_for(status, has_validation_errors).to_owned(),
1405 status: status.as_u16(),
1406 detail: safe_detail,
1407 instance,
1408 code,
1409 request_id,
1410 errors: validation_errors(details),
1411 }
1412}
1413
1414#[must_use]
1417pub(crate) fn problem_details_json_string(
1418 status: StatusCode,
1419 detail: impl Into<String>,
1420 details: Option<&std::collections::HashMap<String, Vec<String>>>,
1421 explicit_type: Option<&'static str>,
1422 request_id: Option<String>,
1423 instance: Option<String>,
1424 expose_internal_detail: bool,
1425) -> String {
1426 let problem = problem_details(
1427 status,
1428 detail.into(),
1429 details,
1430 explicit_type,
1431 request_id,
1432 instance,
1433 expose_internal_detail,
1434 );
1435 problem_details_to_json_string(&problem)
1436}
1437
1438#[must_use]
1440pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
1441 serde_json::to_string(&problem).unwrap_or_else(|_| {
1442 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()
1443 })
1444}
1445
1446fn validation_errors(
1447 details: Option<&std::collections::HashMap<String, Vec<String>>>,
1448) -> Vec<ProblemFieldError> {
1449 let mut errors: Vec<_> = details
1450 .into_iter()
1451 .flat_map(std::collections::HashMap::iter)
1452 .filter(|(_, messages)| !messages.is_empty())
1453 .map(|(field, messages)| ProblemFieldError {
1454 field: field.clone(),
1455 messages: messages.clone(),
1456 })
1457 .collect();
1458 errors.sort_by(|left, right| left.field.cmp(&right.field));
1459 errors
1460}
1461
1462const PROBLEM_TYPE_CONFLICT: &str = "https://autumn.dev/problems/conflict";
1465const PROBLEM_TYPE_GONE: &str = "https://autumn.dev/problems/gone";
1466const PROBLEM_TYPE_QUERY_TIMEOUT: &str = "https://autumn.dev/problems/query-timeout";
1467
1468fn problem_code(
1477 status: StatusCode,
1478 has_validation_errors: bool,
1479 explicit_type: Option<&str>,
1480) -> std::borrow::Cow<'static, str> {
1481 explicit_type.map_or_else(
1482 || std::borrow::Cow::Borrowed(problem_code_for(status, has_validation_errors)),
1483 |type_uri| {
1484 let slug = type_uri.rsplit('/').next().unwrap_or(type_uri);
1485 std::borrow::Cow::Owned(format!("autumn.{}", slug.replace('-', "_")))
1486 },
1487 )
1488}
1489
1490const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
1491 if has_validation_errors {
1492 return "https://autumn.dev/problems/validation-failed";
1493 }
1494
1495 match status {
1496 StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
1497 StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
1498 StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
1499 StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
1500 StatusCode::GONE => "https://autumn.dev/problems/gone",
1501 StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
1502 StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
1503 StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
1504 StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
1505 StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
1506 StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
1507 _ => "about:blank",
1508 }
1509}
1510
1511fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
1512 if has_validation_errors {
1513 return "Validation Failed";
1514 }
1515
1516 match status {
1517 StatusCode::BAD_REQUEST => "Bad Request",
1518 StatusCode::UNAUTHORIZED => "Unauthorized",
1519 StatusCode::FORBIDDEN => "Forbidden",
1520 StatusCode::NOT_FOUND => "Not Found",
1521 StatusCode::GONE => "Gone",
1522 StatusCode::CONFLICT => "Conflict",
1523 StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
1524 StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
1525 StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
1526 StatusCode::NOT_IMPLEMENTED => "Not Implemented",
1527 StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
1528 _ => status.canonical_reason().unwrap_or("Error"),
1529 }
1530}
1531
1532fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
1533 if has_validation_errors {
1534 return "autumn.validation_failed";
1535 }
1536
1537 match status {
1538 StatusCode::BAD_REQUEST => "autumn.bad_request",
1539 StatusCode::UNAUTHORIZED => "autumn.unauthorized",
1540 StatusCode::FORBIDDEN => "autumn.forbidden",
1541 StatusCode::NOT_FOUND => "autumn.not_found",
1542 StatusCode::GONE => "autumn.gone",
1543 StatusCode::CONFLICT => "autumn.conflict",
1544 StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
1545 StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
1546 StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
1547 StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
1548 StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
1549 _ if status.is_client_error() => "autumn.client_error",
1550 _ if status.is_server_error() => "autumn.server_error",
1551 _ => "autumn.error",
1552 }
1553}
1554
1555fn server_error_detail(status: StatusCode) -> String {
1556 match status {
1557 StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
1558 StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
1559 _ => "Internal server error".to_owned(),
1560 }
1561}
1562
1563impl AutumnError {
1564 fn is_deadline_stop(&self) -> bool {
1569 chain_has_deadline_stop(self.inner.as_ref(), true)
1570 }
1571}
1572
1573fn chain_has_deadline_stop(err: &(dyn std::error::Error + 'static), with_stopped: bool) -> bool {
1578 std::iter::successors(Some(err), |err| err.source()).any(|err| {
1579 #[cfg(feature = "http-client")]
1580 if matches!(
1581 err.downcast_ref::<crate::http_client::ClientError>(),
1582 Some(crate::http_client::ClientError::DeadlineExceeded)
1583 ) {
1584 return true;
1585 }
1586 err.downcast_ref::<crate::deadline::DeadlineExceeded>()
1587 .is_some()
1588 || (with_stopped
1589 && err
1590 .downcast_ref::<crate::deadline::DeadlineStopped>()
1591 .is_some())
1592 })
1593}
1594
1595impl IntoResponse for AutumnError {
1596 fn into_response(self) -> Response {
1597 let deadline_stop = self.is_deadline_stop();
1598 let message = self.inner.to_string();
1599 let (status, problem_type) = self.rendered_problem();
1600
1601 let details = self.details.clone();
1602 let cache_idempotency_response = self.cache_idempotency_response;
1603
1604 let error_info = crate::middleware::AutumnErrorInfo {
1607 status,
1608 message: message.clone(),
1609 details: details.clone(),
1610 problem_type,
1611 #[cfg(debug_assertions)]
1612 backtrace_string: self.backtrace_string.clone(),
1613 #[cfg(not(debug_assertions))]
1614 backtrace_string: None,
1615 };
1616
1617 let body = problem_details(
1618 status,
1619 message,
1620 details.as_ref(),
1621 problem_type,
1622 None,
1623 None,
1624 true,
1625 );
1626 let mut response = (status, axum::Json(body)).into_response();
1627 response.headers_mut().insert(
1628 header::CONTENT_TYPE,
1629 HeaderValue::from_static("application/problem+json"),
1630 );
1631 if status == StatusCode::CONFLICT {
1632 response.headers_mut().insert(
1633 "HX-Trigger",
1634 HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
1635 );
1636 }
1637 if cache_idempotency_response {
1638 response
1639 .extensions_mut()
1640 .insert(crate::idempotency::IdempotencyCacheCommittedErrorResponse);
1641 }
1642 if deadline_stop {
1646 response
1647 .extensions_mut()
1648 .insert(crate::router::RequestDeadlineCancelled);
1649 }
1650 response.extensions_mut().insert(error_info);
1651 response
1652 }
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657 use super::*;
1658 use axum::http::StatusCode;
1659
1660 #[test]
1661 fn a_deadline_stop_is_marked_as_cancelled() {
1662 let marked = |error: AutumnError| {
1663 let response = error.into_response();
1664 (
1665 response.status(),
1666 response
1667 .extensions()
1668 .get::<crate::router::RequestDeadlineCancelled>()
1669 .is_some(),
1670 )
1671 };
1672 assert_eq!(
1673 marked(crate::deadline::DeadlineExceeded.into()),
1674 (StatusCode::GATEWAY_TIMEOUT, true)
1675 );
1676 #[cfg(feature = "http-client")]
1677 assert_eq!(
1678 marked(crate::http_client::ClientError::DeadlineExceeded.into()),
1679 (StatusCode::GATEWAY_TIMEOUT, true)
1680 );
1681 assert_eq!(
1682 marked(AutumnError::service_unavailable(
1683 crate::deadline::DeadlineStopped("db wait")
1684 )),
1685 (StatusCode::SERVICE_UNAVAILABLE, true),
1686 "a deadline-cut connection wait keeps its 503"
1687 );
1688 assert_eq!(
1689 marked(std::io::Error::other("boom").into()),
1690 (StatusCode::INTERNAL_SERVER_ERROR, false)
1691 );
1692 }
1693
1694 #[derive(Debug, thiserror::Error)]
1696 #[error("lookup failed")]
1697 struct Wrapped(#[source] Box<dyn std::error::Error + Send + Sync>);
1698
1699 #[test]
1700 fn a_wrapped_deadline_stop_is_a_504_marked_as_cancelled() {
1701 let response =
1702 AutumnError::from(Wrapped(Box::new(crate::deadline::DeadlineExceeded))).into_response();
1703 assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
1704 assert!(
1705 response
1706 .extensions()
1707 .get::<crate::router::RequestDeadlineCancelled>()
1708 .is_some()
1709 );
1710 #[cfg(feature = "http-client")]
1711 {
1712 let response = AutumnError::from(Wrapped(Box::new(
1713 crate::http_client::ClientError::DeadlineExceeded,
1714 )))
1715 .into_response();
1716 assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
1717 assert!(
1718 response
1719 .extensions()
1720 .get::<crate::router::RequestDeadlineCancelled>()
1721 .is_some()
1722 );
1723 }
1724 let response = AutumnError::service_unavailable(Wrapped(Box::new(
1725 crate::deadline::DeadlineStopped("db wait"),
1726 )))
1727 .into_response();
1728 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1729 assert!(
1730 response
1731 .extensions()
1732 .get::<crate::router::RequestDeadlineCancelled>()
1733 .is_some()
1734 );
1735 }
1736
1737 #[cfg(feature = "http-client")]
1738 #[test]
1739 fn a_service_call_the_deadline_stopped_is_a_504() {
1740 let error = AutumnError::from(crate::wire::WireError::Transport {
1741 endpoint: "users.get",
1742 source: crate::http_client::ClientError::DeadlineExceeded,
1743 });
1744 assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
1745 }
1746
1747 #[cfg(feature = "http-client")]
1748 #[test]
1749 fn an_outbound_deadline_exceeded_is_a_504() {
1750 let error = AutumnError::from(crate::http_client::ClientError::DeadlineExceeded);
1751 assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
1752 }
1753
1754 #[test]
1755 fn a_bounded_call_past_its_deadline_is_a_504() {
1756 let error = AutumnError::from(crate::deadline::DeadlineExceeded);
1757 assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
1758 }
1759
1760 #[derive(Debug)]
1761 struct TestError(String);
1762
1763 impl std::fmt::Display for TestError {
1764 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1765 write!(f, "{}", self.0)
1766 }
1767 }
1768
1769 impl std::error::Error for TestError {}
1770
1771 #[derive(Debug)]
1772 struct WrappedError {
1773 message: String,
1774 source: TestError,
1775 }
1776
1777 impl std::fmt::Display for WrappedError {
1778 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779 write!(f, "{}", self.message)
1780 }
1781 }
1782
1783 impl std::error::Error for WrappedError {
1784 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1785 Some(&self.source)
1786 }
1787 }
1788
1789 #[test]
1790 fn blanket_from_defaults_to_500() {
1791 let err: AutumnError = TestError("boom".into()).into();
1792 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1793 }
1794
1795 #[test]
1796 fn internal_server_error_is_500() {
1797 let err = AutumnError::internal_server_error(TestError("boom".into()));
1798 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1799 }
1800
1801 #[test]
1802 fn test_not_found_error() {
1803 let err = AutumnError::not_found(std::io::Error::other("no such user"));
1804 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1805 }
1806
1807 #[test]
1808 fn not_found_is_404() {
1809 let err = AutumnError::not_found(TestError("missing".into()));
1810 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1811 }
1812
1813 #[test]
1814 fn bad_request_is_400() {
1815 let err = AutumnError::bad_request(TestError("invalid input".into()));
1816 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1817 }
1818
1819 #[test]
1820 fn unprocessable_is_422() {
1821 let err = AutumnError::unprocessable(TestError("bad entity".into()));
1822 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1823 }
1824
1825 #[test]
1826 fn unauthorized_is_401() {
1827 let err = AutumnError::unauthorized(TestError("unauthorized".into()));
1828 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1829 }
1830
1831 #[test]
1832 fn forbidden_is_403() {
1833 let err = AutumnError::forbidden(TestError("forbidden".into()));
1834 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1835 }
1836
1837 #[test]
1838 fn validation_is_422() {
1839 let mut details = std::collections::HashMap::new();
1840 details.insert("field".to_string(), vec!["error".to_string()]);
1841 let err = AutumnError::validation(details);
1842 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1843 }
1844
1845 #[test]
1846 fn service_unavailable_is_503() {
1847 let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
1848 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1849 }
1850
1851 #[test]
1852 fn internal_server_error_msg_is_500() {
1853 let err = AutumnError::internal_server_error_msg("db failure");
1854 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
1855 assert_eq!(err.to_string(), "db failure");
1856 }
1857
1858 #[test]
1859 fn not_found_msg_is_404() {
1860 let err = AutumnError::not_found_msg("no such user");
1861 assert_eq!(err.status(), StatusCode::NOT_FOUND);
1862 assert_eq!(err.to_string(), "no such user");
1863 }
1864
1865 #[test]
1866 fn bad_request_msg_is_400() {
1867 let err = AutumnError::bad_request_msg("invalid input");
1868 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1869 }
1870
1871 #[test]
1872 fn unprocessable_msg_is_422() {
1873 let err = AutumnError::unprocessable_msg("title required");
1874 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
1875 }
1876
1877 #[test]
1878 fn unauthorized_msg_is_401() {
1879 let err = AutumnError::unauthorized_msg("login required");
1880 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1881 }
1882
1883 #[test]
1884 fn forbidden_msg_is_403() {
1885 let err = AutumnError::forbidden_msg("no access");
1886 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1887 }
1888
1889 #[test]
1890 fn service_unavailable_msg_is_503() {
1891 let err = AutumnError::service_unavailable_msg("db down");
1892 assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
1893 assert_eq!(err.to_string(), "db down");
1894 }
1895
1896 #[test]
1897 fn with_status_overrides() {
1898 let err: AutumnError = TestError("forbidden".into()).into();
1899 let err = err.with_status(StatusCode::FORBIDDEN);
1900 assert_eq!(err.status(), StatusCode::FORBIDDEN);
1901 }
1902
1903 #[test]
1904 fn display_uses_inner_message() {
1905 let err: AutumnError = TestError("something broke".into()).into();
1906 assert_eq!(err.to_string(), "something broke");
1907 }
1908
1909 #[test]
1910 fn source_chain_lists_inner_sources() {
1911 let err = AutumnError::internal_server_error(WrappedError {
1912 message: "failed to backfill".to_string(),
1913 source: TestError("database connection dropped".to_string()),
1914 });
1915
1916 assert_eq!(
1917 err.source_chain(),
1918 vec!["database connection dropped".to_string()]
1919 );
1920 }
1921
1922 #[test]
1923 fn into_response_has_correct_status() {
1924 let err = AutumnError::not_found(TestError("not found".into()));
1925 let response = err.into_response();
1926 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1927 }
1928
1929 #[tokio::test]
1930 async fn into_response_has_json_body() -> Result<(), axum::Error> {
1931 let err = AutumnError::not_found(TestError("not found".into()));
1932 let response = err.into_response();
1933
1934 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1935 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1936
1937 assert_eq!(json["status"], 404);
1938 assert_eq!(json["detail"], "not found");
1939 assert_eq!(json["code"], "autumn.not_found");
1940 Ok(())
1941 }
1942
1943 #[test]
1944 fn debug_shows_status_and_inner() {
1945 let err = AutumnError::bad_request(TestError("oops".into()));
1946 let debug = format!("{err:?}");
1947 assert!(debug.contains("AutumnError"));
1948 assert!(debug.contains("400"));
1949 }
1950
1951 #[tokio::test]
1952 async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
1953 let err = AutumnError::unprocessable_msg("title required");
1954 let response = err.into_response();
1955
1956 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1957 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1958 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1959 assert_eq!(json["status"], 422);
1960 assert_eq!(json["detail"], "title required");
1961 assert_eq!(json["code"], "autumn.unprocessable_entity");
1962 Ok(())
1963 }
1964
1965 #[tokio::test]
1966 async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
1967 let err = AutumnError::service_unavailable_msg("db down");
1968 let response = err.into_response();
1969
1970 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1971 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
1972 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
1973 assert_eq!(json["status"], 503);
1974 assert_eq!(json["detail"], "db down");
1975 assert_eq!(json["code"], "autumn.service_unavailable");
1976 Ok(())
1977 }
1978
1979 #[test]
1980 fn conflict_is_409() {
1981 let err = AutumnError::conflict(TestError("stale version".into()));
1982 assert_eq!(err.status(), StatusCode::CONFLICT);
1983 }
1984
1985 #[test]
1986 fn conflict_msg_is_409() {
1987 let err = AutumnError::conflict_msg("please reload and retry");
1988 assert_eq!(err.status(), StatusCode::CONFLICT);
1989 assert_eq!(err.to_string(), "please reload and retry");
1990 }
1991
1992 #[test]
1993 fn gone_is_410() {
1994 let err = AutumnError::gone(TestError("sunsetted".into()));
1995 assert_eq!(err.status(), StatusCode::GONE);
1996 }
1997
1998 #[test]
1999 fn gone_msg_is_410() {
2000 let err = AutumnError::gone_msg("API version has been sunsetted");
2001 assert_eq!(err.status(), StatusCode::GONE);
2002 assert_eq!(err.to_string(), "API version has been sunsetted");
2003 }
2004
2005 #[tokio::test]
2006 async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
2007 let err = AutumnError::conflict_msg("version mismatch");
2008 let response = err.into_response();
2009
2010 assert_eq!(response.status(), StatusCode::CONFLICT);
2011 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
2012 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
2013 assert_eq!(json["status"], 409);
2014 assert_eq!(json["detail"], "version mismatch");
2015 assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
2016 assert_eq!(json["title"], "Conflict");
2017 Ok(())
2018 }
2019
2020 #[tokio::test]
2021 async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
2022 let err = AutumnError::conflict_msg("version mismatch");
2023 let response = err.into_response();
2024
2025 assert_eq!(response.status(), StatusCode::CONFLICT);
2026 let hx_trigger = response
2027 .headers()
2028 .get("HX-Trigger")
2029 .expect("HX-Trigger header present");
2030 assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
2031 Ok(())
2032 }
2033
2034 #[cfg(feature = "db")]
2037 mod nul_byte_violation_tests {
2038 use super::*;
2039 use crate::error::is_nul_byte_violation;
2040
2041 #[derive(Debug)]
2047 struct FakeMessageInfo(&'static str, Option<&'static str>);
2048
2049 impl diesel::result::DatabaseErrorInformation for FakeMessageInfo {
2050 fn message(&self) -> &str {
2051 self.0
2052 }
2053 fn details(&self) -> Option<&str> {
2054 self.1
2055 }
2056 fn hint(&self) -> Option<&str> {
2057 None
2058 }
2059 fn table_name(&self) -> Option<&str> {
2060 None
2061 }
2062 fn column_name(&self) -> Option<&str> {
2063 None
2064 }
2065 fn constraint_name(&self) -> Option<&str> {
2066 None
2067 }
2068 fn statement_position(&self) -> Option<i32> {
2069 None
2070 }
2071 }
2072
2073 fn unknown_db_error(message: &'static str) -> diesel::result::Error {
2074 diesel::result::Error::DatabaseError(
2075 diesel::result::DatabaseErrorKind::Unknown,
2076 Box::new(FakeMessageInfo(message, None)),
2077 )
2078 }
2079
2080 fn unknown_db_error_with_detail(
2081 message: &'static str,
2082 detail: &'static str,
2083 ) -> diesel::result::Error {
2084 diesel::result::Error::DatabaseError(
2085 diesel::result::DatabaseErrorKind::Unknown,
2086 Box::new(FakeMessageInfo(message, Some(detail))),
2087 )
2088 }
2089
2090 const PG_JSONB_NUL_MESSAGE: &str = "unsupported Unicode escape sequence";
2092 const PG_JSONB_NUL_DETAIL: &str = r"\u0000 cannot be converted to text.";
2093
2094 #[test]
2095 fn pg_jsonb_nul_error_maps_to_422_not_500() {
2096 let err: AutumnError =
2097 unknown_db_error_with_detail(PG_JSONB_NUL_MESSAGE, PG_JSONB_NUL_DETAIL).into();
2098 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2099 assert!(is_nul_byte_violation(&err));
2100 assert_eq!(err.to_string(), crate::error::NUL_BYTE_REJECTED_MESSAGE);
2101 }
2102
2103 #[test]
2105 fn jsonb_message_without_the_nul_detail_stays_500() {
2106 let err: AutumnError = unknown_db_error(PG_JSONB_NUL_MESSAGE).into();
2107 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2108 let err: AutumnError = unknown_db_error_with_detail(
2109 PG_JSONB_NUL_MESSAGE,
2110 r"\ud83d is an unpaired surrogate.",
2111 )
2112 .into();
2113 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2114 }
2115
2116 #[test]
2118 fn echoed_jsonb_text_does_not_classify() {
2119 let err: AutumnError = unknown_db_error_with_detail(
2120 r#"invalid input syntax for type integer: "unsupported Unicode escape sequence""#,
2121 PG_JSONB_NUL_DETAIL,
2122 )
2123 .into();
2124 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2125 }
2126
2127 const PG_NUL_MESSAGE: &str = r#"invalid byte sequence for encoding "UTF8": 0x00"#;
2129
2130 #[test]
2131 fn pg_nul_byte_error_maps_to_422_not_500() {
2132 let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2133 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2134 }
2135
2136 #[test]
2137 fn predicate_recognizes_the_pg_nul_byte_error() {
2138 let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2139 assert!(is_nul_byte_violation(&err));
2140 }
2141
2142 #[test]
2145 fn localized_pg_nul_byte_message_is_still_recognized() {
2146 let err: AutumnError =
2147 unknown_db_error("ungueltige Byte-Sequenz fuer Kodierung \u{ab}UTF8\u{bb}: 0x00")
2148 .into();
2149 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2150 }
2151
2152 #[test]
2157 fn the_raw_postgres_message_is_not_the_client_facing_message() {
2158 let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2159 assert_eq!(err.to_string(), crate::error::NUL_BYTE_REJECTED_MESSAGE);
2160 assert!(!err.to_string().contains("UTF8"));
2161 }
2162
2163 #[test]
2166 fn the_original_diesel_error_survives_as_a_source() {
2167 let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2168 let inner = err
2169 .downcast_chain_ref::<diesel::result::Error>()
2170 .expect("the diesel error must stay reachable through the chain");
2171 assert!(inner.to_string().contains("0x00"));
2172 }
2173
2174 #[test]
2177 fn other_db_errors_still_map_to_500() {
2178 for message in [
2179 "division by zero",
2180 "relation \"posts\" does not exist",
2181 "invalid input syntax for type bytea: 0x00",
2183 ] {
2184 let err: AutumnError = unknown_db_error(message).into();
2185 assert_eq!(
2186 err.status(),
2187 StatusCode::INTERNAL_SERVER_ERROR,
2188 "unexpectedly reclassified: {message}"
2189 );
2190 assert!(!is_nul_byte_violation(&err), "false positive: {message}");
2191 }
2192 }
2193
2194 #[test]
2200 fn attacker_supplied_text_echoed_into_a_message_does_not_classify() {
2201 for message in [
2202 "invalid input syntax for type integer: \"UTF: 0x00\"",
2204 "invalid input syntax for type uuid: \"0x00 UTF8\"",
2205 "column \"0x00 UTF8 encoding\" does not exist",
2207 ] {
2208 let err: AutumnError = unknown_db_error(message).into();
2209 assert_eq!(
2210 err.status(),
2211 StatusCode::INTERNAL_SERVER_ERROR,
2212 "client-spellable message was reclassified: {message}"
2213 );
2214 }
2215 }
2216
2217 #[test]
2221 fn predicate_and_status_agree_through_a_wrapping_error() {
2222 #[derive(Debug)]
2223 struct Wrapper(diesel::result::Error);
2224 impl std::fmt::Display for Wrapper {
2225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2226 write!(f, "repository failed")
2227 }
2228 }
2229 impl std::error::Error for Wrapper {
2230 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2231 Some(&self.0)
2232 }
2233 }
2234
2235 let err: AutumnError = Wrapper(unknown_db_error(PG_NUL_MESSAGE)).into();
2236 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2237 assert!(
2238 is_nul_byte_violation(&err),
2239 "the predicate must agree with the status the From impl chose"
2240 );
2241 }
2242
2243 #[test]
2244 fn predicate_is_false_for_non_db_errors() {
2245 let err = AutumnError::internal_server_error_msg(PG_NUL_MESSAGE);
2246 assert!(!is_nul_byte_violation(&err));
2247 }
2248
2249 #[allow(unused_imports)]
2253 use crate::error::{
2254 __name_nul_fields, MaybeJsonBody, MaybeJsonBodyFallback as _,
2255 MaybeJsonBodyViaSerialize as _, nul_byte_json_fields,
2256 };
2257
2258 #[test]
2259 fn json_fields_name_top_level_nested_and_array_strings() {
2260 let body = serde_json::json!({
2261 "title": "ok",
2262 "body": "a\u{0}b",
2263 "address": {"street": "x\u{0}"},
2264 "items": [{"sku": "fine"}, {"sku": "\u{0}"}],
2265 "tags": ["ok", "n\u{0}"],
2266 "count": 3,
2267 });
2268 let mut fields = nul_byte_json_fields(&body);
2269 fields.sort();
2270 assert_eq!(
2271 fields,
2272 ["address.street", "body", "items[1].sku", "tags[1]"]
2273 );
2274 }
2275
2276 #[test]
2277 fn json_fields_are_empty_without_a_nul() {
2278 let body = serde_json::json!({"title": "ok", "n": [1, {"a": "b"}]});
2279 assert!(nul_byte_json_fields(&body).is_empty());
2280 }
2281
2282 #[test]
2283 fn a_nul_violation_gets_the_field_named() {
2284 let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2285 let err = __name_nul_fields(err, || Some(serde_json::json!({"body": "a\u{0}b"})));
2286 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2287 let details = err.details().expect("the field is named");
2288 assert_eq!(details.len(), 1);
2289 assert_eq!(
2290 details["body"],
2291 [crate::form::NUL_CHARACTER_FIELD_ERROR.to_owned()]
2292 );
2293 assert!(is_nul_byte_violation(&err), "the original stays reachable");
2294 }
2295
2296 #[test]
2297 fn a_jsonb_nul_violation_gets_the_field_named() {
2298 let err: AutumnError =
2299 unknown_db_error_with_detail(PG_JSONB_NUL_MESSAGE, PG_JSONB_NUL_DETAIL).into();
2300 let err = __name_nul_fields(err, || Some(serde_json::json!({"meta": {"k": "\u{0}"}})));
2301 assert!(err.details().expect("named").contains_key("meta.k"));
2302 }
2303
2304 #[test]
2305 fn other_errors_are_left_alone_and_the_body_is_never_built() {
2306 let built = std::cell::Cell::new(false);
2307 let err: AutumnError = unknown_db_error("division by zero").into();
2308 let err = __name_nul_fields(err, || {
2309 built.set(true);
2310 Some(serde_json::json!({"body": "\u{0}"}))
2311 });
2312 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
2313 assert!(!err.has_field_errors());
2314 assert!(!built.get(), "the cold path must not serialize");
2315 }
2316
2317 #[test]
2318 fn an_unlocatable_nul_keeps_the_empty_error_list() {
2319 let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
2320 let err = __name_nul_fields(err, || None);
2321 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
2322 assert!(!err.has_field_errors());
2323 }
2324
2325 #[allow(clippy::needless_borrow)]
2327 #[test]
2328 fn only_serializable_payloads_expose_a_json_body() {
2329 #[derive(serde::Serialize)]
2330 struct Yes {
2331 body: String,
2332 }
2333 struct No;
2334 let yes = Yes { body: "x".into() };
2335 assert_eq!(
2336 (&MaybeJsonBody(&yes)).autumn_json_body(),
2337 Some(serde_json::json!({"body": "x"}))
2338 );
2339 assert_eq!((&MaybeJsonBody(&No)).autumn_json_body(), None);
2340 }
2341 }
2342
2343 #[cfg(feature = "db")]
2346 mod unique_violation_field_tests {
2347 use super::*;
2348 use crate::error::unique_violation_field;
2349
2350 #[derive(Debug)]
2351 struct FakeDbErrorInfo {
2352 constraint: Option<&'static str>,
2353 }
2354
2355 impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
2356 fn message(&self) -> &'static str {
2357 "duplicate key value violates unique constraint"
2358 }
2359 fn details(&self) -> Option<&str> {
2360 None
2361 }
2362 fn hint(&self) -> Option<&str> {
2363 None
2364 }
2365 fn table_name(&self) -> Option<&str> {
2366 None
2367 }
2368 fn column_name(&self) -> Option<&str> {
2369 None
2370 }
2371 fn constraint_name(&self) -> Option<&str> {
2372 self.constraint
2373 }
2374 fn statement_position(&self) -> Option<i32> {
2375 None
2376 }
2377 }
2378
2379 fn unique_violation(constraint: Option<&'static str>) -> diesel::result::Error {
2380 diesel::result::Error::DatabaseError(
2381 diesel::result::DatabaseErrorKind::UniqueViolation,
2382 Box::new(FakeDbErrorInfo { constraint }),
2383 )
2384 }
2385
2386 const MAPPING: &[(&str, &str, &str)] =
2387 &[("idx_users_email_unique", "email", "has already been taken")];
2388
2389 #[test]
2390 fn matches_constraint_name_to_field_and_message() {
2391 let err: AutumnError = AutumnError::internal_server_error(unique_violation(Some(
2392 "idx_users_email_unique",
2393 )));
2394 assert_eq!(
2395 unique_violation_field(&err, MAPPING),
2396 Some(("email", "has already been taken"))
2397 );
2398 }
2399
2400 #[test]
2401 fn returns_none_for_unrecognized_constraint() {
2402 let err: AutumnError =
2403 AutumnError::internal_server_error(unique_violation(Some("some_other_constraint")));
2404 assert_eq!(unique_violation_field(&err, MAPPING), None);
2405 }
2406
2407 #[test]
2408 fn returns_none_when_constraint_name_is_absent() {
2409 let err: AutumnError = AutumnError::internal_server_error(unique_violation(None));
2410 assert_eq!(unique_violation_field(&err, MAPPING), None);
2411 }
2412
2413 #[derive(Debug)]
2425 struct FakeSqliteDbErrorInfo {
2426 message: &'static str,
2427 }
2428
2429 impl diesel::result::DatabaseErrorInformation for FakeSqliteDbErrorInfo {
2430 fn message(&self) -> &str {
2431 self.message
2432 }
2433 fn details(&self) -> Option<&str> {
2434 None
2435 }
2436 fn hint(&self) -> Option<&str> {
2437 None
2438 }
2439 fn table_name(&self) -> Option<&str> {
2440 None
2441 }
2442 fn column_name(&self) -> Option<&str> {
2443 None
2444 }
2445 fn constraint_name(&self) -> Option<&str> {
2446 None
2447 }
2448 fn statement_position(&self) -> Option<i32> {
2449 None
2450 }
2451 }
2452
2453 fn sqlite_unique_violation(message: &'static str) -> diesel::result::Error {
2454 diesel::result::Error::DatabaseError(
2455 diesel::result::DatabaseErrorKind::UniqueViolation,
2456 Box::new(FakeSqliteDbErrorInfo { message }),
2457 )
2458 }
2459
2460 #[test]
2461 fn matches_sqlite_message_column_to_field_and_message() {
2462 let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2463 "UNIQUE constraint failed: users.email",
2464 ));
2465 assert_eq!(
2466 unique_violation_field(&err, MAPPING),
2467 Some(("email", "has already been taken"))
2468 );
2469 }
2470
2471 #[test]
2472 fn matches_sqlite_message_composite_index_by_any_column() {
2473 const COMPOSITE: &[(&str, &str, &str)] =
2474 &[("idx_invitations_pending_email", "email", "already pending")];
2475 let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2476 "UNIQUE constraint failed: invitations.tenant_id, invitations.email",
2477 ));
2478 assert_eq!(
2479 unique_violation_field(&err, COMPOSITE),
2480 Some(("email", "already pending"))
2481 );
2482 }
2483
2484 #[test]
2485 fn returns_none_for_sqlite_message_naming_an_unmapped_column() {
2486 let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2487 "UNIQUE constraint failed: users.username",
2488 ));
2489 assert_eq!(unique_violation_field(&err, MAPPING), None);
2490 }
2491
2492 #[test]
2493 fn returns_none_for_unrecognized_message_shape() {
2494 let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
2495 "some other database error text",
2496 ));
2497 assert_eq!(unique_violation_field(&err, MAPPING), None);
2498 }
2499
2500 #[test]
2501 fn returns_none_for_non_unique_violation_db_errors() {
2502 let err: AutumnError =
2503 AutumnError::internal_server_error(diesel::result::Error::NotFound);
2504 assert_eq!(unique_violation_field(&err, MAPPING), None);
2505 }
2506
2507 #[test]
2508 fn returns_none_for_non_db_errors() {
2509 let err = AutumnError::internal_server_error_msg("plain string error");
2510 assert_eq!(unique_violation_field(&err, MAPPING), None);
2511 }
2512
2513 #[test]
2514 fn recovers_diesel_error_through_repository_style_blanket_from() {
2515 fn insert() -> Result<(), diesel::result::Error> {
2520 Err(unique_violation(Some("idx_users_email_unique")))
2521 }
2522 fn handler() -> Result<(), AutumnError> {
2523 insert()?;
2524 Ok(())
2525 }
2526 let err = handler().unwrap_err();
2527 assert_eq!(
2528 unique_violation_field(&err, MAPPING),
2529 Some(("email", "has already been taken"))
2530 );
2531 }
2532 }
2533
2534 fn details_map(pairs: &[(&str, &[&str])]) -> std::collections::HashMap<String, Vec<String>> {
2537 pairs
2538 .iter()
2539 .map(|(field, messages)| {
2540 (
2541 (*field).to_owned(),
2542 messages.iter().map(|m| (*m).to_owned()).collect(),
2543 )
2544 })
2545 .collect()
2546 }
2547
2548 #[test]
2549 fn details_reads_back_the_validation_map() {
2550 let err = AutumnError::validation(details_map(&[("title", &["must be 1-120 characters"])]));
2551 let details = err.details().expect("validation error carries details");
2552 assert_eq!(
2553 details.get("title").map(Vec::as_slice),
2554 Some(["must be 1-120 characters".to_owned()].as_slice())
2555 );
2556 }
2557
2558 #[test]
2559 fn details_is_none_for_non_validation_errors() {
2560 assert!(
2561 AutumnError::not_found_msg("no such user")
2562 .details()
2563 .is_none()
2564 );
2565 assert!(
2566 AutumnError::conflict_msg("stale version")
2567 .details()
2568 .is_none()
2569 );
2570 }
2571
2572 #[test]
2573 fn code_names_each_constructor() {
2574 let cases: Vec<(AutumnError, &str)> = vec![
2575 (
2576 AutumnError::validation(details_map(&[("title", &["too short"])])),
2577 "autumn.validation_failed",
2578 ),
2579 (AutumnError::not_found_msg("gone"), "autumn.not_found"),
2580 (AutumnError::bad_request_msg("bad"), "autumn.bad_request"),
2581 (AutumnError::conflict_msg("stale"), "autumn.conflict"),
2582 (AutumnError::gone_msg("sunset"), "autumn.gone"),
2583 (
2584 AutumnError::query_timeout("statement_timeout"),
2585 "autumn.query_timeout",
2586 ),
2587 (
2588 AutumnError::internal_server_error_msg("boom"),
2589 "autumn.internal_server_error",
2590 ),
2591 ];
2592
2593 for (err, expected) in cases {
2594 assert_eq!(err.code(), expected, "code() for {err:?}");
2595 }
2596 }
2597
2598 #[test]
2599 fn code_follows_the_statement_timeout_reclassification() {
2600 let err =
2603 AutumnError::internal_server_error_msg("canceling statement due to statement timeout");
2604 assert_eq!(err.code(), "autumn.query_timeout");
2605 }
2606
2607 #[test]
2608 fn display_lists_the_failing_fields() {
2609 let err = AutumnError::validation(details_map(&[
2610 ("title", &["must be 1-120 characters"]),
2611 ("email", &["invalid"]),
2612 ]));
2613 assert_eq!(
2614 err.to_string(),
2615 "Validation failed: email: invalid; title: must be 1-120 characters"
2616 );
2617 }
2618
2619 #[test]
2620 fn display_field_order_does_not_follow_the_map() {
2621 let fields: &[(&str, &[&str])] = &[
2625 ("title", &["e"]),
2626 ("email", &["e"]),
2627 ("author", &["e"]),
2628 ("body", &["e"]),
2629 ("slug", &["e"]),
2630 ];
2631 let expected = "Validation failed: author: e; body: e; email: e; slug: e; title: e";
2632
2633 for _ in 0..64 {
2634 assert_eq!(
2635 AutumnError::validation(details_map(fields)).to_string(),
2636 expected
2637 );
2638 }
2639 }
2640
2641 #[test]
2642 fn display_sorts_non_ascii_fields_deterministically() {
2643 let fields: &[(&str, &[&str])] = &[
2644 ("überschrift", &["zu kurz"]),
2645 ("邮箱", &["无效"]),
2646 ("email", &["invalide"]),
2647 ];
2648 let first = AutumnError::validation(details_map(fields)).to_string();
2649 for _ in 0..32 {
2650 assert_eq!(
2651 AutumnError::validation(details_map(fields)).to_string(),
2652 first
2653 );
2654 }
2655 assert!(first.contains("邮箱: 无效"), "{first}");
2656 }
2657
2658 #[test]
2659 fn message_is_not_redacted_for_a_server_error() {
2660 let err = AutumnError::internal_server_error_msg("password=hunter2 in dsn");
2663 assert_eq!(err.message(), "password=hunter2 in dsn");
2664
2665 let redacted = problem_details(err.status(), err.message(), None, None, None, None, false);
2666 assert_eq!(redacted.detail, "Internal server error");
2667 }
2668
2669 #[test]
2670 fn message_is_the_wrapped_error_without_the_fields() {
2671 let err = AutumnError::validation(details_map(&[("email", &["invalid"])]));
2672 assert_eq!(err.message(), "Validation failed");
2673 assert_eq!(err.to_string(), "Validation failed: email: invalid");
2674 assert_eq!(AutumnError::not_found_msg("gone").message(), "gone");
2675 }
2676
2677 #[test]
2678 fn display_joins_multiple_messages_for_one_field() {
2679 let err = AutumnError::validation(details_map(&[(
2680 "password",
2681 &["too short", "needs a digit"],
2682 )]));
2683 assert_eq!(
2684 err.to_string(),
2685 "Validation failed: password: too short, needs a digit"
2686 );
2687 }
2688
2689 #[test]
2690 fn display_falls_back_to_the_title_without_field_messages() {
2691 assert_eq!(
2692 AutumnError::validation(details_map(&[])).to_string(),
2693 "Validation failed"
2694 );
2695 assert_eq!(
2696 AutumnError::validation(details_map(&[("title", &[])])).to_string(),
2697 "Validation failed"
2698 );
2699 }
2700
2701 #[tokio::test]
2702 async fn json_errors_skip_a_field_with_no_messages_like_display_does() -> Result<(), axum::Error>
2703 {
2704 let err = AutumnError::validation(details_map(&[("title", &[])]));
2710 let response = err.into_response();
2711 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
2712 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
2713 assert_eq!(json["errors"], serde_json::json!([]));
2714 Ok(())
2715 }
2716
2717 #[test]
2718 fn display_is_unchanged_for_non_validation_errors() {
2719 assert_eq!(
2720 AutumnError::not_found_msg("no such user").to_string(),
2721 "no such user"
2722 );
2723 }
2724
2725 #[tokio::test]
2726 async fn code_equals_the_code_in_the_rendered_body() -> Result<(), axum::Error> {
2727 let builders: Vec<fn() -> AutumnError> = vec![
2728 || AutumnError::validation(details_map(&[("title", &["too short"])])),
2729 || AutumnError::not_found_msg("gone"),
2730 || AutumnError::conflict_msg("stale"),
2731 || AutumnError::gone_msg("sunset"),
2732 || AutumnError::query_timeout("statement_timeout"),
2733 || AutumnError::internal_server_error_msg("boom"),
2734 || AutumnError::internal_server_error_msg("ERROR: 57014 query canceled"),
2735 ];
2736
2737 for build in builders {
2738 let expected = build().code();
2739 let body =
2740 axum::body::to_bytes(build().into_response().into_body(), usize::MAX).await?;
2741 let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
2742 assert_eq!(json["code"], &*expected);
2743 }
2744 Ok(())
2745 }
2746
2747 #[test]
2748 fn the_assigned_status_is_not_a_redaction_guard() {
2749 let err = AutumnError::bad_request_msg(
2754 "db: canceling statement due to statement timeout (dsn password=hunter2)",
2755 );
2756 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
2757
2758 let (status, problem_type) = err.rendered_problem();
2759 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
2760
2761 let rendered =
2762 problem_details(status, err.message(), None, problem_type, None, None, false);
2763 assert_eq!(rendered.detail, "Service unavailable");
2764 assert!(err.message().contains("password=hunter2"));
2765 }
2766}