use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Serialize;
#[derive(Debug)]
struct StringError(String);
impl std::fmt::Display for StringError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for StringError {}
#[derive(Clone, Debug, Serialize)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
pub detail: String,
pub instance: Option<String>,
pub code: String,
pub request_id: Option<String>,
pub errors: Vec<ProblemFieldError>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ProblemFieldError {
pub field: String,
pub messages: Vec<String>,
}
pub struct AutumnError {
inner: Box<dyn std::error::Error + Send + Sync>,
status: StatusCode,
details: Option<std::collections::HashMap<String, Vec<String>>>,
problem_type: Option<&'static str>,
cache_idempotency_response: bool,
#[cfg(debug_assertions)]
pub(crate) backtrace_string: Option<String>,
}
pub type AutumnResult<T> = Result<T, AutumnError>;
impl<E> From<E> for AutumnError
where
E: std::error::Error + Send + Sync + 'static,
{
fn from(err: E) -> Self {
#[cfg(feature = "db")]
if error_chain_is_pg_nul_rejection(&err) {
return Self {
inner: Box::new(NulByteRejected(err)),
status: StatusCode::UNPROCESSABLE_ENTITY,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
};
}
let mut status = StatusCode::INTERNAL_SERVER_ERROR;
let any_err: &dyn std::any::Any = &err;
if std::any::type_name::<E>().contains("CircuitBreakerError")
&& err.to_string() == "circuit breaker is open"
{
status = StatusCode::SERVICE_UNAVAILABLE;
}
#[cfg(feature = "http-client")]
{
if matches!(
any_err.downcast_ref::<crate::http_client::ClientError>(),
Some(
crate::http_client::ClientError::CircuitBreakerOpen
| crate::http_client::ClientError::ThrottledLocally { .. }
| crate::http_client::ClientError::FaultInjected(_)
)
) {
status = StatusCode::SERVICE_UNAVAILABLE;
}
}
#[cfg(feature = "http-client")]
if let Some(wire_err) = any_err.downcast_ref::<crate::wire::WireError>() {
status = wire_err.status();
}
if let Some(push_err) = any_err.downcast_ref::<crate::push::PushError>() {
status = push_err.status();
}
#[cfg(feature = "constela")]
if let Some(constela_err) = any_err.downcast_ref::<crate::constela::ConstelaError>() {
status = constela_err.http_status();
}
#[cfg(feature = "sqlite")]
if let Some(fleet_err) = any_err.downcast_ref::<crate::db::fleet::FleetError>() {
status = fleet_err.http_status();
}
if let Some(money_err) = any_err.downcast_ref::<crate::money::MoneyError>() {
status = money_err.http_status();
}
#[cfg(feature = "db")]
if let Some(ledger_err) = any_err.downcast_ref::<crate::money::ledger::LedgerError>() {
status = ledger_err.http_status();
}
if let Some(form_err) = any_err.downcast_ref::<crate::form::FormDecodeError>() {
status = form_err.status();
}
if matches!(
any_err.downcast_ref::<crate::lock::LockError>(),
Some(
crate::lock::LockError::PoolUnavailable(_)
| crate::lock::LockError::Timeout { .. }
| crate::lock::LockError::LeaseLost { .. }
)
) {
status = StatusCode::SERVICE_UNAVAILABLE;
}
#[cfg(feature = "mail")]
{
if let Some(crate::mail::MailError::RuntimeUnavailable(msg)) =
any_err.downcast_ref::<crate::mail::MailError>()
&& msg.contains("circuit breaker is open")
{
status = StatusCode::SERVICE_UNAVAILABLE;
}
}
if any_err
.downcast_ref::<crate::tenant_cell::QuotaExceeded>()
.is_some()
|| any_err
.downcast_ref::<crate::tenant_cell::TenantAllocationError>()
.is_some()
{
status = StatusCode::SERVICE_UNAVAILABLE;
}
if chain_has_deadline_stop(&err, false) {
status = StatusCode::GATEWAY_TIMEOUT;
}
Self {
inner: Box::new(err),
status,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
}
impl AutumnError {
#[must_use]
pub const fn with_status(mut self, status: StatusCode) -> Self {
self.status = status;
self
}
pub fn internal_server_error(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::INTERNAL_SERVER_ERROR,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn not_found(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::NOT_FOUND,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn bad_request(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::BAD_REQUEST,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn unprocessable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::UNPROCESSABLE_ENTITY,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn service_unavailable(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::SERVICE_UNAVAILABLE,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::UNAUTHORIZED,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn forbidden(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::FORBIDDEN,
details: None,
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
#[must_use]
pub fn validation(details: std::collections::HashMap<String, Vec<String>>) -> Self {
Self {
inner: Box::new(StringError("Validation failed".into())),
status: StatusCode::UNPROCESSABLE_ENTITY,
details: Some(details),
problem_type: None,
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn internal_server_error_msg(msg: impl Into<String>) -> Self {
Self::internal_server_error(StringError(msg.into()))
}
pub fn not_found_msg(msg: impl Into<String>) -> Self {
Self::not_found(StringError(msg.into()))
}
pub fn bad_request_msg(msg: impl Into<String>) -> Self {
Self::bad_request(StringError(msg.into()))
}
pub fn unprocessable_msg(msg: impl Into<String>) -> Self {
Self::unprocessable(StringError(msg.into()))
}
pub fn unauthorized_msg(msg: impl Into<String>) -> Self {
Self::unauthorized(StringError(msg.into()))
}
pub fn forbidden_msg(msg: impl Into<String>) -> Self {
Self::forbidden(StringError(msg.into()))
}
pub fn service_unavailable_msg(msg: impl Into<String>) -> Self {
Self::service_unavailable(StringError(msg.into()))
}
pub fn conflict(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::CONFLICT,
details: None,
problem_type: Some(PROBLEM_TYPE_CONFLICT),
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn conflict_msg(msg: impl Into<String>) -> Self {
Self::conflict(StringError(msg.into()))
}
pub fn gone(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
inner: Box::new(err),
status: StatusCode::GONE,
details: None,
problem_type: Some(PROBLEM_TYPE_GONE),
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
pub fn gone_msg(msg: impl Into<String>) -> Self {
Self::gone(StringError(msg.into()))
}
pub fn query_timeout(msg: impl Into<String>) -> Self {
Self {
inner: Box::new(StringError(msg.into())),
status: StatusCode::SERVICE_UNAVAILABLE,
details: None,
problem_type: Some(PROBLEM_TYPE_QUERY_TIMEOUT),
cache_idempotency_response: false,
#[cfg(debug_assertions)]
backtrace_string: Some(format!("{}", std::backtrace::Backtrace::force_capture())),
}
}
#[must_use]
pub const fn status(&self) -> StatusCode {
self.status
}
#[must_use]
pub const fn details(&self) -> Option<&std::collections::HashMap<String, Vec<String>>> {
self.details.as_ref()
}
#[must_use]
pub fn message(&self) -> String {
self.inner.to_string()
}
#[must_use]
pub fn code(&self) -> std::borrow::Cow<'static, str> {
let (status, problem_type) = self.rendered_problem();
problem_code(status, self.has_field_errors(), problem_type)
}
fn has_field_errors(&self) -> bool {
self.details.as_ref().is_some_and(|map| !map.is_empty())
}
pub(crate) fn rendered_problem(&self) -> (StatusCode, Option<&'static str>) {
let lowered = self.inner.to_string().to_lowercase();
if lowered.contains("57014")
|| lowered.contains("query_canceled")
|| lowered.contains("canceling statement due to statement timeout")
|| lowered.contains("statement timeout")
|| lowered.contains("query canceled")
{
return (
StatusCode::SERVICE_UNAVAILABLE,
Some(PROBLEM_TYPE_QUERY_TIMEOUT),
);
}
(self.status, self.problem_type)
}
#[doc(hidden)]
#[must_use]
pub(crate) const fn cache_idempotency_response(mut self) -> Self {
self.cache_idempotency_response = true;
self
}
#[must_use]
pub fn source_chain(&self) -> Vec<String> {
let mut chain = Vec::new();
let mut source = self.inner.source();
while let Some(error) = source {
chain.push(error.to_string());
source = error.source();
}
chain
}
#[must_use]
pub fn downcast_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
let err: &(dyn std::error::Error + 'static) = self.inner.as_ref();
err.downcast_ref::<T>()
}
#[must_use]
pub fn downcast_chain_ref<T: std::error::Error + 'static>(&self) -> Option<&T> {
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self.inner.as_ref());
while let Some(err) = current {
if let Some(t) = err.downcast_ref::<T>() {
return Some(t);
}
current = err.source();
}
None
}
}
#[cfg(feature = "db")]
#[must_use]
pub fn unique_violation_field<'a>(
err: &AutumnError,
mapping: &'a [(&str, &str, &str)],
) -> Option<(&'a str, &'a str)> {
let diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
info,
) = err.downcast_ref::<diesel::result::Error>()?
else {
return None;
};
if let Some(constraint) = info.constraint_name() {
return mapping
.iter()
.find(|(c, _, _)| *c == constraint)
.map(|(_, field, message)| (*field, *message));
}
let violated_columns = sqlite_unique_violation_columns(info.message())?;
mapping
.iter()
.find(|(_, field, _)| violated_columns.iter().any(|column| column == field))
.map(|(_, field, message)| (*field, *message))
}
#[cfg(feature = "db")]
fn sqlite_unique_violation_columns(message: &str) -> Option<Vec<&str>> {
let columns = message.strip_prefix("UNIQUE constraint failed: ")?;
Some(
columns
.split(", ")
.map(|qualified| qualified.rsplit('.').next().unwrap_or(qualified))
.collect(),
)
}
pub const NUL_BYTE_REJECTED_MESSAGE: &str =
"The submitted text contains a NUL character (0x00), which cannot be stored.";
#[cfg(feature = "db")]
#[derive(Debug)]
pub(crate) struct NulByteRejected<E>(E);
#[cfg(feature = "db")]
impl<E> std::fmt::Display for NulByteRejected<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(NUL_BYTE_REJECTED_MESSAGE)
}
}
#[cfg(feature = "db")]
impl<E: std::error::Error + 'static> std::error::Error for NulByteRejected<E> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
#[cfg(feature = "db")]
#[must_use]
pub fn is_nul_byte_violation(err: &AutumnError) -> bool {
error_chain_is_pg_nul_rejection(err.inner.as_ref())
}
#[cfg(feature = "db")]
pub(crate) fn error_chain_is_pg_nul_rejection(err: &(dyn std::error::Error + 'static)) -> bool {
let mut current = Some(err);
while let Some(cause) = current {
if cause
.downcast_ref::<diesel::result::Error>()
.is_some_and(is_pg_nul_byte_error)
{
return true;
}
current = cause.source();
}
false
}
#[cfg(feature = "db")]
fn is_pg_nul_byte_error(err: &diesel::result::Error) -> bool {
let diesel::result::Error::DatabaseError(_, info) = err else {
return false;
};
pg_message_is_nul_rejection(info.message()) || pg_error_is_jsonb_nul_rejection(info.as_ref())
}
#[cfg(feature = "db")]
fn pg_message_is_nul_rejection(message: &str) -> bool {
message.ends_with(": 0x00") && message.contains("UTF")
}
#[cfg(feature = "db")]
fn pg_error_is_jsonb_nul_rejection(info: &dyn diesel::result::DatabaseErrorInformation) -> bool {
info.message() == "unsupported Unicode escape sequence"
&& info.details() == Some(r"\u0000 cannot be converted to text.")
}
#[cfg(feature = "db")]
#[must_use]
pub fn nul_byte_json_fields(body: &serde_json::Value) -> Vec<String> {
fn walk(value: &serde_json::Value, path: &str, out: &mut Vec<String>) {
match value {
serde_json::Value::String(text) if text.contains('\0') => out.push(path.to_owned()),
serde_json::Value::Array(items) => {
for (index, item) in items.iter().enumerate() {
walk(item, &format!("{path}[{index}]"), out);
}
}
serde_json::Value::Object(map) => {
for (key, item) in map {
let child = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
walk(item, &child, out);
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(body, "", &mut out);
out
}
#[cfg(feature = "db")]
#[doc(hidden)]
#[must_use]
pub fn __name_nul_fields(
mut err: AutumnError,
body: impl FnOnce() -> Option<serde_json::Value>,
) -> AutumnError {
if err.details.is_some() || !is_nul_byte_violation(&err) {
return err;
}
let fields = body().map(|body| nul_byte_json_fields(&body));
if let Some(fields) = fields.filter(|fields| !fields.is_empty()) {
err.details = Some(
fields
.into_iter()
.map(|field| {
(
field,
vec![crate::form::NUL_CHARACTER_FIELD_ERROR.to_owned()],
)
})
.collect(),
);
}
err
}
#[cfg(feature = "db")]
#[doc(hidden)]
pub struct MaybeJsonBody<'a, T>(pub &'a T);
#[cfg(feature = "db")]
#[doc(hidden)]
pub trait MaybeJsonBodyViaSerialize {
fn autumn_json_body(&self) -> Option<serde_json::Value>;
}
#[cfg(feature = "db")]
impl<T: serde::Serialize> MaybeJsonBodyViaSerialize for MaybeJsonBody<'_, T> {
fn autumn_json_body(&self) -> Option<serde_json::Value> {
serde_json::to_value(self.0).ok()
}
}
#[cfg(feature = "db")]
#[doc(hidden)]
pub trait MaybeJsonBodyFallback {
fn autumn_json_body(&self) -> Option<serde_json::Value>;
}
#[cfg(feature = "db")]
impl<T> MaybeJsonBodyFallback for &MaybeJsonBody<'_, T> {
fn autumn_json_body(&self) -> Option<serde_json::Value> {
None
}
}
impl std::fmt::Display for AutumnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.inner)?;
let mut fields: Vec<_> = self
.details
.iter()
.flatten()
.filter(|(_, messages)| !messages.is_empty())
.collect();
fields.sort_by_key(|(left, _)| *left);
for (index, (field, messages)) in fields.into_iter().enumerate() {
f.write_str(if index == 0 { ": " } else { "; " })?;
write!(f, "{field}: ")?;
for (position, message) in messages.iter().enumerate() {
if position > 0 {
f.write_str(", ")?;
}
f.write_str(message)?;
}
}
Ok(())
}
}
impl std::fmt::Debug for AutumnError {
#[allow(clippy::missing_fields_in_debug)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AutumnError")
.field("status", &self.status)
.field("inner", &self.inner)
.field("details", &self.details)
.field("problem_type", &self.problem_type)
.field(
"cache_idempotency_response",
&self.cache_idempotency_response,
)
.finish_non_exhaustive()
}
}
impl ProblemDetails {
#[must_use]
pub fn new(
status: StatusCode,
detail: impl Into<String>,
details: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> Self {
problem_details(status, detail.into(), details, None, None, None, true)
}
}
#[must_use]
pub(crate) fn problem_details(
status: StatusCode,
detail: String,
details: Option<&std::collections::HashMap<String, Vec<String>>>,
explicit_type: Option<&'static str>,
request_id: Option<String>,
instance: Option<String>,
expose_internal_detail: bool,
) -> ProblemDetails {
let has_validation_errors = details.is_some_and(|map| !map.is_empty());
let safe_detail = if status.is_server_error() && !expose_internal_detail {
server_error_detail(status)
} else {
detail
};
let code = problem_code(status, has_validation_errors, explicit_type).into_owned();
ProblemDetails {
type_uri: explicit_type
.unwrap_or_else(|| problem_type_for(status, has_validation_errors))
.to_owned(),
title: problem_title_for(status, has_validation_errors).to_owned(),
status: status.as_u16(),
detail: safe_detail,
instance,
code,
request_id,
errors: validation_errors(details),
}
}
#[must_use]
pub(crate) fn problem_details_json_string(
status: StatusCode,
detail: impl Into<String>,
details: Option<&std::collections::HashMap<String, Vec<String>>>,
explicit_type: Option<&'static str>,
request_id: Option<String>,
instance: Option<String>,
expose_internal_detail: bool,
) -> String {
let problem = problem_details(
status,
detail.into(),
details,
explicit_type,
request_id,
instance,
expose_internal_detail,
);
problem_details_to_json_string(&problem)
}
#[must_use]
pub(crate) fn problem_details_to_json_string(problem: &ProblemDetails) -> String {
serde_json::to_string(&problem).unwrap_or_else(|_| {
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()
})
}
fn validation_errors(
details: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> Vec<ProblemFieldError> {
let mut errors: Vec<_> = details
.into_iter()
.flat_map(std::collections::HashMap::iter)
.filter(|(_, messages)| !messages.is_empty())
.map(|(field, messages)| ProblemFieldError {
field: field.clone(),
messages: messages.clone(),
})
.collect();
errors.sort_by(|left, right| left.field.cmp(&right.field));
errors
}
const PROBLEM_TYPE_CONFLICT: &str = "https://autumn.dev/problems/conflict";
const PROBLEM_TYPE_GONE: &str = "https://autumn.dev/problems/gone";
const PROBLEM_TYPE_QUERY_TIMEOUT: &str = "https://autumn.dev/problems/query-timeout";
fn problem_code(
status: StatusCode,
has_validation_errors: bool,
explicit_type: Option<&str>,
) -> std::borrow::Cow<'static, str> {
explicit_type.map_or_else(
|| std::borrow::Cow::Borrowed(problem_code_for(status, has_validation_errors)),
|type_uri| {
let slug = type_uri.rsplit('/').next().unwrap_or(type_uri);
std::borrow::Cow::Owned(format!("autumn.{}", slug.replace('-', "_")))
},
)
}
const fn problem_type_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
if has_validation_errors {
return "https://autumn.dev/problems/validation-failed";
}
match status {
StatusCode::BAD_REQUEST => "https://autumn.dev/problems/bad-request",
StatusCode::UNAUTHORIZED => "https://autumn.dev/problems/unauthorized",
StatusCode::FORBIDDEN => "https://autumn.dev/problems/forbidden",
StatusCode::NOT_FOUND => "https://autumn.dev/problems/not-found",
StatusCode::GONE => "https://autumn.dev/problems/gone",
StatusCode::CONFLICT => "https://autumn.dev/problems/conflict",
StatusCode::PAYLOAD_TOO_LARGE => "https://autumn.dev/problems/payload-too-large",
StatusCode::UNPROCESSABLE_ENTITY => "https://autumn.dev/problems/unprocessable-entity",
StatusCode::INTERNAL_SERVER_ERROR => "https://autumn.dev/problems/internal-server-error",
StatusCode::NOT_IMPLEMENTED => "https://autumn.dev/problems/not-implemented",
StatusCode::SERVICE_UNAVAILABLE => "https://autumn.dev/problems/service-unavailable",
_ => "about:blank",
}
}
fn problem_title_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
if has_validation_errors {
return "Validation Failed";
}
match status {
StatusCode::BAD_REQUEST => "Bad Request",
StatusCode::UNAUTHORIZED => "Unauthorized",
StatusCode::FORBIDDEN => "Forbidden",
StatusCode::NOT_FOUND => "Not Found",
StatusCode::GONE => "Gone",
StatusCode::CONFLICT => "Conflict",
StatusCode::PAYLOAD_TOO_LARGE => "Payload Too Large",
StatusCode::UNPROCESSABLE_ENTITY => "Unprocessable Entity",
StatusCode::INTERNAL_SERVER_ERROR => "Internal Server Error",
StatusCode::NOT_IMPLEMENTED => "Not Implemented",
StatusCode::SERVICE_UNAVAILABLE => "Service Unavailable",
_ => status.canonical_reason().unwrap_or("Error"),
}
}
fn problem_code_for(status: StatusCode, has_validation_errors: bool) -> &'static str {
if has_validation_errors {
return "autumn.validation_failed";
}
match status {
StatusCode::BAD_REQUEST => "autumn.bad_request",
StatusCode::UNAUTHORIZED => "autumn.unauthorized",
StatusCode::FORBIDDEN => "autumn.forbidden",
StatusCode::NOT_FOUND => "autumn.not_found",
StatusCode::GONE => "autumn.gone",
StatusCode::CONFLICT => "autumn.conflict",
StatusCode::PAYLOAD_TOO_LARGE => "autumn.payload_too_large",
StatusCode::UNPROCESSABLE_ENTITY => "autumn.unprocessable_entity",
StatusCode::INTERNAL_SERVER_ERROR => "autumn.internal_server_error",
StatusCode::NOT_IMPLEMENTED => "autumn.not_implemented",
StatusCode::SERVICE_UNAVAILABLE => "autumn.service_unavailable",
_ if status.is_client_error() => "autumn.client_error",
_ if status.is_server_error() => "autumn.server_error",
_ => "autumn.error",
}
}
fn server_error_detail(status: StatusCode) -> String {
match status {
StatusCode::SERVICE_UNAVAILABLE => "Service unavailable".to_owned(),
StatusCode::NOT_IMPLEMENTED => "Not implemented".to_owned(),
_ => "Internal server error".to_owned(),
}
}
impl AutumnError {
fn is_deadline_stop(&self) -> bool {
chain_has_deadline_stop(self.inner.as_ref(), true)
}
}
fn chain_has_deadline_stop(err: &(dyn std::error::Error + 'static), with_stopped: bool) -> bool {
std::iter::successors(Some(err), |err| err.source()).any(|err| {
#[cfg(feature = "http-client")]
if matches!(
err.downcast_ref::<crate::http_client::ClientError>(),
Some(crate::http_client::ClientError::DeadlineExceeded)
) {
return true;
}
err.downcast_ref::<crate::deadline::DeadlineExceeded>()
.is_some()
|| (with_stopped
&& err
.downcast_ref::<crate::deadline::DeadlineStopped>()
.is_some())
})
}
impl IntoResponse for AutumnError {
fn into_response(self) -> Response {
let deadline_stop = self.is_deadline_stop();
let message = self.inner.to_string();
let (status, problem_type) = self.rendered_problem();
let details = self.details.clone();
let cache_idempotency_response = self.cache_idempotency_response;
let error_info = crate::middleware::AutumnErrorInfo {
status,
message: message.clone(),
details: details.clone(),
problem_type,
#[cfg(debug_assertions)]
backtrace_string: self.backtrace_string.clone(),
#[cfg(not(debug_assertions))]
backtrace_string: None,
};
let body = problem_details(
status,
message,
details.as_ref(),
problem_type,
None,
None,
true,
);
let mut response = (status, axum::Json(body)).into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/problem+json"),
);
if status == StatusCode::CONFLICT {
response.headers_mut().insert(
"HX-Trigger",
HeaderValue::from_static(r#"{"autumn:conflict":true}"#),
);
}
if cache_idempotency_response {
response
.extensions_mut()
.insert(crate::idempotency::IdempotencyCacheCommittedErrorResponse);
}
if deadline_stop {
response
.extensions_mut()
.insert(crate::router::RequestDeadlineCancelled);
}
response.extensions_mut().insert(error_info);
response
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
#[test]
fn a_deadline_stop_is_marked_as_cancelled() {
let marked = |error: AutumnError| {
let response = error.into_response();
(
response.status(),
response
.extensions()
.get::<crate::router::RequestDeadlineCancelled>()
.is_some(),
)
};
assert_eq!(
marked(crate::deadline::DeadlineExceeded.into()),
(StatusCode::GATEWAY_TIMEOUT, true)
);
#[cfg(feature = "http-client")]
assert_eq!(
marked(crate::http_client::ClientError::DeadlineExceeded.into()),
(StatusCode::GATEWAY_TIMEOUT, true)
);
assert_eq!(
marked(AutumnError::service_unavailable(
crate::deadline::DeadlineStopped("db wait")
)),
(StatusCode::SERVICE_UNAVAILABLE, true),
"a deadline-cut connection wait keeps its 503"
);
assert_eq!(
marked(std::io::Error::other("boom").into()),
(StatusCode::INTERNAL_SERVER_ERROR, false)
);
}
#[derive(Debug, thiserror::Error)]
#[error("lookup failed")]
struct Wrapped(#[source] Box<dyn std::error::Error + Send + Sync>);
#[test]
fn a_wrapped_deadline_stop_is_a_504_marked_as_cancelled() {
let response =
AutumnError::from(Wrapped(Box::new(crate::deadline::DeadlineExceeded))).into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
assert!(
response
.extensions()
.get::<crate::router::RequestDeadlineCancelled>()
.is_some()
);
#[cfg(feature = "http-client")]
{
let response = AutumnError::from(Wrapped(Box::new(
crate::http_client::ClientError::DeadlineExceeded,
)))
.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
assert!(
response
.extensions()
.get::<crate::router::RequestDeadlineCancelled>()
.is_some()
);
}
let response = AutumnError::service_unavailable(Wrapped(Box::new(
crate::deadline::DeadlineStopped("db wait"),
)))
.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
response
.extensions()
.get::<crate::router::RequestDeadlineCancelled>()
.is_some()
);
}
#[cfg(feature = "http-client")]
#[test]
fn a_service_call_the_deadline_stopped_is_a_504() {
let error = AutumnError::from(crate::wire::WireError::Transport {
endpoint: "users.get",
source: crate::http_client::ClientError::DeadlineExceeded,
});
assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[cfg(feature = "http-client")]
#[test]
fn an_outbound_deadline_exceeded_is_a_504() {
let error = AutumnError::from(crate::http_client::ClientError::DeadlineExceeded);
assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn a_bounded_call_past_its_deadline_is_a_504() {
let error = AutumnError::from(crate::deadline::DeadlineExceeded);
assert_eq!(error.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[derive(Debug)]
struct TestError(String);
impl std::fmt::Display for TestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for TestError {}
#[derive(Debug)]
struct WrappedError {
message: String,
source: TestError,
}
impl std::fmt::Display for WrappedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for WrappedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
#[test]
fn blanket_from_defaults_to_500() {
let err: AutumnError = TestError("boom".into()).into();
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn internal_server_error_is_500() {
let err = AutumnError::internal_server_error(TestError("boom".into()));
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn test_not_found_error() {
let err = AutumnError::not_found(std::io::Error::other("no such user"));
assert_eq!(err.status(), StatusCode::NOT_FOUND);
}
#[test]
fn not_found_is_404() {
let err = AutumnError::not_found(TestError("missing".into()));
assert_eq!(err.status(), StatusCode::NOT_FOUND);
}
#[test]
fn bad_request_is_400() {
let err = AutumnError::bad_request(TestError("invalid input".into()));
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn unprocessable_is_422() {
let err = AutumnError::unprocessable(TestError("bad entity".into()));
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn unauthorized_is_401() {
let err = AutumnError::unauthorized(TestError("unauthorized".into()));
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn forbidden_is_403() {
let err = AutumnError::forbidden(TestError("forbidden".into()));
assert_eq!(err.status(), StatusCode::FORBIDDEN);
}
#[test]
fn validation_is_422() {
let mut details = std::collections::HashMap::new();
details.insert("field".to_string(), vec!["error".to_string()]);
let err = AutumnError::validation(details);
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn service_unavailable_is_503() {
let err = AutumnError::service_unavailable(TestError("pool exhausted".into()));
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn internal_server_error_msg_is_500() {
let err = AutumnError::internal_server_error_msg("db failure");
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(err.to_string(), "db failure");
}
#[test]
fn not_found_msg_is_404() {
let err = AutumnError::not_found_msg("no such user");
assert_eq!(err.status(), StatusCode::NOT_FOUND);
assert_eq!(err.to_string(), "no such user");
}
#[test]
fn bad_request_msg_is_400() {
let err = AutumnError::bad_request_msg("invalid input");
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn unprocessable_msg_is_422() {
let err = AutumnError::unprocessable_msg("title required");
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn unauthorized_msg_is_401() {
let err = AutumnError::unauthorized_msg("login required");
assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn forbidden_msg_is_403() {
let err = AutumnError::forbidden_msg("no access");
assert_eq!(err.status(), StatusCode::FORBIDDEN);
}
#[test]
fn service_unavailable_msg_is_503() {
let err = AutumnError::service_unavailable_msg("db down");
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(err.to_string(), "db down");
}
#[test]
fn with_status_overrides() {
let err: AutumnError = TestError("forbidden".into()).into();
let err = err.with_status(StatusCode::FORBIDDEN);
assert_eq!(err.status(), StatusCode::FORBIDDEN);
}
#[test]
fn display_uses_inner_message() {
let err: AutumnError = TestError("something broke".into()).into();
assert_eq!(err.to_string(), "something broke");
}
#[test]
fn source_chain_lists_inner_sources() {
let err = AutumnError::internal_server_error(WrappedError {
message: "failed to backfill".to_string(),
source: TestError("database connection dropped".to_string()),
});
assert_eq!(
err.source_chain(),
vec!["database connection dropped".to_string()]
);
}
#[test]
fn into_response_has_correct_status() {
let err = AutumnError::not_found(TestError("not found".into()));
let response = err.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn into_response_has_json_body() -> Result<(), axum::Error> {
let err = AutumnError::not_found(TestError("not found".into()));
let response = err.into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
assert_eq!(json["status"], 404);
assert_eq!(json["detail"], "not found");
assert_eq!(json["code"], "autumn.not_found");
Ok(())
}
#[test]
fn debug_shows_status_and_inner() {
let err = AutumnError::bad_request(TestError("oops".into()));
let debug = format!("{err:?}");
assert!(debug.contains("AutumnError"));
assert!(debug.contains("400"));
}
#[tokio::test]
async fn msg_constructor_produces_valid_json_response() -> Result<(), axum::Error> {
let err = AutumnError::unprocessable_msg("title required");
let response = err.into_response();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
assert_eq!(json["status"], 422);
assert_eq!(json["detail"], "title required");
assert_eq!(json["code"], "autumn.unprocessable_entity");
Ok(())
}
#[tokio::test]
async fn service_unavailable_response_is_503() -> Result<(), axum::Error> {
let err = AutumnError::service_unavailable_msg("db down");
let response = err.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
assert_eq!(json["status"], 503);
assert_eq!(json["detail"], "db down");
assert_eq!(json["code"], "autumn.service_unavailable");
Ok(())
}
#[test]
fn conflict_is_409() {
let err = AutumnError::conflict(TestError("stale version".into()));
assert_eq!(err.status(), StatusCode::CONFLICT);
}
#[test]
fn conflict_msg_is_409() {
let err = AutumnError::conflict_msg("please reload and retry");
assert_eq!(err.status(), StatusCode::CONFLICT);
assert_eq!(err.to_string(), "please reload and retry");
}
#[test]
fn gone_is_410() {
let err = AutumnError::gone(TestError("sunsetted".into()));
assert_eq!(err.status(), StatusCode::GONE);
}
#[test]
fn gone_msg_is_410() {
let err = AutumnError::gone_msg("API version has been sunsetted");
assert_eq!(err.status(), StatusCode::GONE);
assert_eq!(err.to_string(), "API version has been sunsetted");
}
#[tokio::test]
async fn conflict_response_is_409_json() -> Result<(), axum::Error> {
let err = AutumnError::conflict_msg("version mismatch");
let response = err.into_response();
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
assert_eq!(json["status"], 409);
assert_eq!(json["detail"], "version mismatch");
assert_eq!(json["type"], "https://autumn.dev/problems/conflict");
assert_eq!(json["title"], "Conflict");
Ok(())
}
#[tokio::test]
async fn conflict_response_has_hx_trigger_header() -> Result<(), axum::Error> {
let err = AutumnError::conflict_msg("version mismatch");
let response = err.into_response();
assert_eq!(response.status(), StatusCode::CONFLICT);
let hx_trigger = response
.headers()
.get("HX-Trigger")
.expect("HX-Trigger header present");
assert_eq!(hx_trigger, r#"{"autumn:conflict":true}"#);
Ok(())
}
#[cfg(feature = "db")]
mod nul_byte_violation_tests {
use super::*;
use crate::error::is_nul_byte_violation;
#[derive(Debug)]
struct FakeMessageInfo(&'static str, Option<&'static str>);
impl diesel::result::DatabaseErrorInformation for FakeMessageInfo {
fn message(&self) -> &str {
self.0
}
fn details(&self) -> Option<&str> {
self.1
}
fn hint(&self) -> Option<&str> {
None
}
fn table_name(&self) -> Option<&str> {
None
}
fn column_name(&self) -> Option<&str> {
None
}
fn constraint_name(&self) -> Option<&str> {
None
}
fn statement_position(&self) -> Option<i32> {
None
}
}
fn unknown_db_error(message: &'static str) -> diesel::result::Error {
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::Unknown,
Box::new(FakeMessageInfo(message, None)),
)
}
fn unknown_db_error_with_detail(
message: &'static str,
detail: &'static str,
) -> diesel::result::Error {
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::Unknown,
Box::new(FakeMessageInfo(message, Some(detail))),
)
}
const PG_JSONB_NUL_MESSAGE: &str = "unsupported Unicode escape sequence";
const PG_JSONB_NUL_DETAIL: &str = r"\u0000 cannot be converted to text.";
#[test]
fn pg_jsonb_nul_error_maps_to_422_not_500() {
let err: AutumnError =
unknown_db_error_with_detail(PG_JSONB_NUL_MESSAGE, PG_JSONB_NUL_DETAIL).into();
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(is_nul_byte_violation(&err));
assert_eq!(err.to_string(), crate::error::NUL_BYTE_REJECTED_MESSAGE);
}
#[test]
fn jsonb_message_without_the_nul_detail_stays_500() {
let err: AutumnError = unknown_db_error(PG_JSONB_NUL_MESSAGE).into();
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
let err: AutumnError = unknown_db_error_with_detail(
PG_JSONB_NUL_MESSAGE,
r"\ud83d is an unpaired surrogate.",
)
.into();
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn echoed_jsonb_text_does_not_classify() {
let err: AutumnError = unknown_db_error_with_detail(
r#"invalid input syntax for type integer: "unsupported Unicode escape sequence""#,
PG_JSONB_NUL_DETAIL,
)
.into();
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
const PG_NUL_MESSAGE: &str = r#"invalid byte sequence for encoding "UTF8": 0x00"#;
#[test]
fn pg_nul_byte_error_maps_to_422_not_500() {
let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn predicate_recognizes_the_pg_nul_byte_error() {
let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
assert!(is_nul_byte_violation(&err));
}
#[test]
fn localized_pg_nul_byte_message_is_still_recognized() {
let err: AutumnError =
unknown_db_error("ungueltige Byte-Sequenz fuer Kodierung \u{ab}UTF8\u{bb}: 0x00")
.into();
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn the_raw_postgres_message_is_not_the_client_facing_message() {
let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
assert_eq!(err.to_string(), crate::error::NUL_BYTE_REJECTED_MESSAGE);
assert!(!err.to_string().contains("UTF8"));
}
#[test]
fn the_original_diesel_error_survives_as_a_source() {
let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
let inner = err
.downcast_chain_ref::<diesel::result::Error>()
.expect("the diesel error must stay reachable through the chain");
assert!(inner.to_string().contains("0x00"));
}
#[test]
fn other_db_errors_still_map_to_500() {
for message in [
"division by zero",
"relation \"posts\" does not exist",
"invalid input syntax for type bytea: 0x00",
] {
let err: AutumnError = unknown_db_error(message).into();
assert_eq!(
err.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"unexpectedly reclassified: {message}"
);
assert!(!is_nul_byte_violation(&err), "false positive: {message}");
}
}
#[test]
fn attacker_supplied_text_echoed_into_a_message_does_not_classify() {
for message in [
"invalid input syntax for type integer: \"UTF: 0x00\"",
"invalid input syntax for type uuid: \"0x00 UTF8\"",
"column \"0x00 UTF8 encoding\" does not exist",
] {
let err: AutumnError = unknown_db_error(message).into();
assert_eq!(
err.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"client-spellable message was reclassified: {message}"
);
}
}
#[test]
fn predicate_and_status_agree_through_a_wrapping_error() {
#[derive(Debug)]
struct Wrapper(diesel::result::Error);
impl std::fmt::Display for Wrapper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "repository failed")
}
}
impl std::error::Error for Wrapper {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
let err: AutumnError = Wrapper(unknown_db_error(PG_NUL_MESSAGE)).into();
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(
is_nul_byte_violation(&err),
"the predicate must agree with the status the From impl chose"
);
}
#[test]
fn predicate_is_false_for_non_db_errors() {
let err = AutumnError::internal_server_error_msg(PG_NUL_MESSAGE);
assert!(!is_nul_byte_violation(&err));
}
#[allow(unused_imports)]
use crate::error::{
__name_nul_fields, MaybeJsonBody, MaybeJsonBodyFallback as _,
MaybeJsonBodyViaSerialize as _, nul_byte_json_fields,
};
#[test]
fn json_fields_name_top_level_nested_and_array_strings() {
let body = serde_json::json!({
"title": "ok",
"body": "a\u{0}b",
"address": {"street": "x\u{0}"},
"items": [{"sku": "fine"}, {"sku": "\u{0}"}],
"tags": ["ok", "n\u{0}"],
"count": 3,
});
let mut fields = nul_byte_json_fields(&body);
fields.sort();
assert_eq!(
fields,
["address.street", "body", "items[1].sku", "tags[1]"]
);
}
#[test]
fn json_fields_are_empty_without_a_nul() {
let body = serde_json::json!({"title": "ok", "n": [1, {"a": "b"}]});
assert!(nul_byte_json_fields(&body).is_empty());
}
#[test]
fn a_nul_violation_gets_the_field_named() {
let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
let err = __name_nul_fields(err, || Some(serde_json::json!({"body": "a\u{0}b"})));
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
let details = err.details().expect("the field is named");
assert_eq!(details.len(), 1);
assert_eq!(
details["body"],
[crate::form::NUL_CHARACTER_FIELD_ERROR.to_owned()]
);
assert!(is_nul_byte_violation(&err), "the original stays reachable");
}
#[test]
fn a_jsonb_nul_violation_gets_the_field_named() {
let err: AutumnError =
unknown_db_error_with_detail(PG_JSONB_NUL_MESSAGE, PG_JSONB_NUL_DETAIL).into();
let err = __name_nul_fields(err, || Some(serde_json::json!({"meta": {"k": "\u{0}"}})));
assert!(err.details().expect("named").contains_key("meta.k"));
}
#[test]
fn other_errors_are_left_alone_and_the_body_is_never_built() {
let built = std::cell::Cell::new(false);
let err: AutumnError = unknown_db_error("division by zero").into();
let err = __name_nul_fields(err, || {
built.set(true);
Some(serde_json::json!({"body": "\u{0}"}))
});
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(!err.has_field_errors());
assert!(!built.get(), "the cold path must not serialize");
}
#[test]
fn an_unlocatable_nul_keeps_the_empty_error_list() {
let err: AutumnError = unknown_db_error(PG_NUL_MESSAGE).into();
let err = __name_nul_fields(err, || None);
assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(!err.has_field_errors());
}
#[allow(clippy::needless_borrow)]
#[test]
fn only_serializable_payloads_expose_a_json_body() {
#[derive(serde::Serialize)]
struct Yes {
body: String,
}
struct No;
let yes = Yes { body: "x".into() };
assert_eq!(
(&MaybeJsonBody(&yes)).autumn_json_body(),
Some(serde_json::json!({"body": "x"}))
);
assert_eq!((&MaybeJsonBody(&No)).autumn_json_body(), None);
}
}
#[cfg(feature = "db")]
mod unique_violation_field_tests {
use super::*;
use crate::error::unique_violation_field;
#[derive(Debug)]
struct FakeDbErrorInfo {
constraint: Option<&'static str>,
}
impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
fn message(&self) -> &'static str {
"duplicate key value violates unique constraint"
}
fn details(&self) -> Option<&str> {
None
}
fn hint(&self) -> Option<&str> {
None
}
fn table_name(&self) -> Option<&str> {
None
}
fn column_name(&self) -> Option<&str> {
None
}
fn constraint_name(&self) -> Option<&str> {
self.constraint
}
fn statement_position(&self) -> Option<i32> {
None
}
}
fn unique_violation(constraint: Option<&'static str>) -> diesel::result::Error {
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
Box::new(FakeDbErrorInfo { constraint }),
)
}
const MAPPING: &[(&str, &str, &str)] =
&[("idx_users_email_unique", "email", "has already been taken")];
#[test]
fn matches_constraint_name_to_field_and_message() {
let err: AutumnError = AutumnError::internal_server_error(unique_violation(Some(
"idx_users_email_unique",
)));
assert_eq!(
unique_violation_field(&err, MAPPING),
Some(("email", "has already been taken"))
);
}
#[test]
fn returns_none_for_unrecognized_constraint() {
let err: AutumnError =
AutumnError::internal_server_error(unique_violation(Some("some_other_constraint")));
assert_eq!(unique_violation_field(&err, MAPPING), None);
}
#[test]
fn returns_none_when_constraint_name_is_absent() {
let err: AutumnError = AutumnError::internal_server_error(unique_violation(None));
assert_eq!(unique_violation_field(&err, MAPPING), None);
}
#[derive(Debug)]
struct FakeSqliteDbErrorInfo {
message: &'static str,
}
impl diesel::result::DatabaseErrorInformation for FakeSqliteDbErrorInfo {
fn message(&self) -> &str {
self.message
}
fn details(&self) -> Option<&str> {
None
}
fn hint(&self) -> Option<&str> {
None
}
fn table_name(&self) -> Option<&str> {
None
}
fn column_name(&self) -> Option<&str> {
None
}
fn constraint_name(&self) -> Option<&str> {
None
}
fn statement_position(&self) -> Option<i32> {
None
}
}
fn sqlite_unique_violation(message: &'static str) -> diesel::result::Error {
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
Box::new(FakeSqliteDbErrorInfo { message }),
)
}
#[test]
fn matches_sqlite_message_column_to_field_and_message() {
let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
"UNIQUE constraint failed: users.email",
));
assert_eq!(
unique_violation_field(&err, MAPPING),
Some(("email", "has already been taken"))
);
}
#[test]
fn matches_sqlite_message_composite_index_by_any_column() {
const COMPOSITE: &[(&str, &str, &str)] =
&[("idx_invitations_pending_email", "email", "already pending")];
let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
"UNIQUE constraint failed: invitations.tenant_id, invitations.email",
));
assert_eq!(
unique_violation_field(&err, COMPOSITE),
Some(("email", "already pending"))
);
}
#[test]
fn returns_none_for_sqlite_message_naming_an_unmapped_column() {
let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
"UNIQUE constraint failed: users.username",
));
assert_eq!(unique_violation_field(&err, MAPPING), None);
}
#[test]
fn returns_none_for_unrecognized_message_shape() {
let err: AutumnError = AutumnError::internal_server_error(sqlite_unique_violation(
"some other database error text",
));
assert_eq!(unique_violation_field(&err, MAPPING), None);
}
#[test]
fn returns_none_for_non_unique_violation_db_errors() {
let err: AutumnError =
AutumnError::internal_server_error(diesel::result::Error::NotFound);
assert_eq!(unique_violation_field(&err, MAPPING), None);
}
#[test]
fn returns_none_for_non_db_errors() {
let err = AutumnError::internal_server_error_msg("plain string error");
assert_eq!(unique_violation_field(&err, MAPPING), None);
}
#[test]
fn recovers_diesel_error_through_repository_style_blanket_from() {
fn insert() -> Result<(), diesel::result::Error> {
Err(unique_violation(Some("idx_users_email_unique")))
}
fn handler() -> Result<(), AutumnError> {
insert()?;
Ok(())
}
let err = handler().unwrap_err();
assert_eq!(
unique_violation_field(&err, MAPPING),
Some(("email", "has already been taken"))
);
}
}
fn details_map(pairs: &[(&str, &[&str])]) -> std::collections::HashMap<String, Vec<String>> {
pairs
.iter()
.map(|(field, messages)| {
(
(*field).to_owned(),
messages.iter().map(|m| (*m).to_owned()).collect(),
)
})
.collect()
}
#[test]
fn details_reads_back_the_validation_map() {
let err = AutumnError::validation(details_map(&[("title", &["must be 1-120 characters"])]));
let details = err.details().expect("validation error carries details");
assert_eq!(
details.get("title").map(Vec::as_slice),
Some(["must be 1-120 characters".to_owned()].as_slice())
);
}
#[test]
fn details_is_none_for_non_validation_errors() {
assert!(
AutumnError::not_found_msg("no such user")
.details()
.is_none()
);
assert!(
AutumnError::conflict_msg("stale version")
.details()
.is_none()
);
}
#[test]
fn code_names_each_constructor() {
let cases: Vec<(AutumnError, &str)> = vec![
(
AutumnError::validation(details_map(&[("title", &["too short"])])),
"autumn.validation_failed",
),
(AutumnError::not_found_msg("gone"), "autumn.not_found"),
(AutumnError::bad_request_msg("bad"), "autumn.bad_request"),
(AutumnError::conflict_msg("stale"), "autumn.conflict"),
(AutumnError::gone_msg("sunset"), "autumn.gone"),
(
AutumnError::query_timeout("statement_timeout"),
"autumn.query_timeout",
),
(
AutumnError::internal_server_error_msg("boom"),
"autumn.internal_server_error",
),
];
for (err, expected) in cases {
assert_eq!(err.code(), expected, "code() for {err:?}");
}
}
#[test]
fn code_follows_the_statement_timeout_reclassification() {
let err =
AutumnError::internal_server_error_msg("canceling statement due to statement timeout");
assert_eq!(err.code(), "autumn.query_timeout");
}
#[test]
fn display_lists_the_failing_fields() {
let err = AutumnError::validation(details_map(&[
("title", &["must be 1-120 characters"]),
("email", &["invalid"]),
]));
assert_eq!(
err.to_string(),
"Validation failed: email: invalid; title: must be 1-120 characters"
);
}
#[test]
fn display_field_order_does_not_follow_the_map() {
let fields: &[(&str, &[&str])] = &[
("title", &["e"]),
("email", &["e"]),
("author", &["e"]),
("body", &["e"]),
("slug", &["e"]),
];
let expected = "Validation failed: author: e; body: e; email: e; slug: e; title: e";
for _ in 0..64 {
assert_eq!(
AutumnError::validation(details_map(fields)).to_string(),
expected
);
}
}
#[test]
fn display_sorts_non_ascii_fields_deterministically() {
let fields: &[(&str, &[&str])] = &[
("überschrift", &["zu kurz"]),
("邮箱", &["æ— æ•ˆ"]),
("email", &["invalide"]),
];
let first = AutumnError::validation(details_map(fields)).to_string();
for _ in 0..32 {
assert_eq!(
AutumnError::validation(details_map(fields)).to_string(),
first
);
}
assert!(first.contains("邮箱: æ— æ•ˆ"), "{first}");
}
#[test]
fn message_is_not_redacted_for_a_server_error() {
let err = AutumnError::internal_server_error_msg("password=hunter2 in dsn");
assert_eq!(err.message(), "password=hunter2 in dsn");
let redacted = problem_details(err.status(), err.message(), None, None, None, None, false);
assert_eq!(redacted.detail, "Internal server error");
}
#[test]
fn message_is_the_wrapped_error_without_the_fields() {
let err = AutumnError::validation(details_map(&[("email", &["invalid"])]));
assert_eq!(err.message(), "Validation failed");
assert_eq!(err.to_string(), "Validation failed: email: invalid");
assert_eq!(AutumnError::not_found_msg("gone").message(), "gone");
}
#[test]
fn display_joins_multiple_messages_for_one_field() {
let err = AutumnError::validation(details_map(&[(
"password",
&["too short", "needs a digit"],
)]));
assert_eq!(
err.to_string(),
"Validation failed: password: too short, needs a digit"
);
}
#[test]
fn display_falls_back_to_the_title_without_field_messages() {
assert_eq!(
AutumnError::validation(details_map(&[])).to_string(),
"Validation failed"
);
assert_eq!(
AutumnError::validation(details_map(&[("title", &[])])).to_string(),
"Validation failed"
);
}
#[tokio::test]
async fn json_errors_skip_a_field_with_no_messages_like_display_does() -> Result<(), axum::Error>
{
let err = AutumnError::validation(details_map(&[("title", &[])]));
let response = err.into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
assert_eq!(json["errors"], serde_json::json!([]));
Ok(())
}
#[test]
fn display_is_unchanged_for_non_validation_errors() {
assert_eq!(
AutumnError::not_found_msg("no such user").to_string(),
"no such user"
);
}
#[tokio::test]
async fn code_equals_the_code_in_the_rendered_body() -> Result<(), axum::Error> {
let builders: Vec<fn() -> AutumnError> = vec![
|| AutumnError::validation(details_map(&[("title", &["too short"])])),
|| AutumnError::not_found_msg("gone"),
|| AutumnError::conflict_msg("stale"),
|| AutumnError::gone_msg("sunset"),
|| AutumnError::query_timeout("statement_timeout"),
|| AutumnError::internal_server_error_msg("boom"),
|| AutumnError::internal_server_error_msg("ERROR: 57014 query canceled"),
];
for build in builders {
let expected = build().code();
let body =
axum::body::to_bytes(build().into_response().into_body(), usize::MAX).await?;
let json: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
assert_eq!(json["code"], &*expected);
}
Ok(())
}
#[test]
fn the_assigned_status_is_not_a_redaction_guard() {
let err = AutumnError::bad_request_msg(
"db: canceling statement due to statement timeout (dsn password=hunter2)",
);
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
let (status, problem_type) = err.rendered_problem();
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
let rendered =
problem_details(status, err.message(), None, problem_type, None, None, false);
assert_eq!(rendered.detail, "Service unavailable");
assert!(err.message().contains("password=hunter2"));
}
}