use sqlx::error::DatabaseError;
use sqlx::postgres::{PgDatabaseError, PgErrorPosition};
use thiserror::Error;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DbErrorInfo {
pub schema: Option<String>,
pub table: Option<String>,
pub column: Option<String>,
pub datatype: Option<String>,
pub constraint: Option<String>,
pub where_: Option<String>,
pub position: Option<DbErrorPosition>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DbErrorPosition {
Original(usize),
Internal {
position: usize,
query: String,
},
}
impl DbErrorInfo {
fn from_sqlx_database_error(db: &(dyn DatabaseError + 'static)) -> Self {
if let Some(pg) = db.try_downcast_ref::<PgDatabaseError>() {
return Self::from_sqlx_pg_error(pg);
}
Self {
table: db.table().map(ToOwned::to_owned),
constraint: db.constraint().map(ToOwned::to_owned),
..Self::default()
}
}
fn from_sqlx_pg_error(db: &PgDatabaseError) -> Self {
Self {
schema: db.schema().map(ToOwned::to_owned),
table: db.table().map(ToOwned::to_owned),
column: db.column().map(ToOwned::to_owned),
datatype: db.data_type().map(ToOwned::to_owned),
constraint: db.constraint().map(ToOwned::to_owned),
where_: db.r#where().map(ToOwned::to_owned),
position: db.position().map(DbErrorPosition::from),
}
}
}
impl From<PgErrorPosition<'_>> for DbErrorPosition {
fn from(position: PgErrorPosition<'_>) -> Self {
match position {
PgErrorPosition::Original(position) => Self::Original(position),
PgErrorPosition::Internal { position, query } => Self::Internal {
position,
query: query.to_owned(),
},
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConstraintError {
pub constraint: Option<String>,
pub detail: Option<String>,
pub info: DbErrorInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ColumnError {
pub column: Option<String>,
pub info: DbErrorInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct PgFailure {
pub message: String,
pub detail: Option<String>,
pub hint: Option<String>,
pub info: DbErrorInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct DatabaseFailure {
pub code: String,
pub message: String,
pub detail: Option<String>,
pub hint: Option<String>,
pub constraint: Option<String>,
pub table: Option<String>,
pub column: Option<String>,
pub info: DbErrorInfo,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct OperatorError {
pub field: String,
pub operator: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct SearchValueError {
pub field: String,
pub expected: &'static str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct WriteTargetError {
pub statement: &'static str,
pub source_kind: &'static str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CteShapeError {
pub name: String,
pub message: &'static str,
}
impl ConstraintError {
pub fn new(constraint: Option<String>, detail: Option<String>, info: DbErrorInfo) -> Self {
Self {
constraint,
detail,
info,
}
}
}
impl ColumnError {
pub fn new(column: Option<String>, info: DbErrorInfo) -> Self {
Self { column, info }
}
}
impl PgFailure {
pub fn new(
message: impl Into<String>,
detail: Option<String>,
hint: Option<String>,
info: DbErrorInfo,
) -> Self {
Self {
message: message.into(),
detail,
hint,
info,
}
}
}
impl DatabaseFailure {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
detail: None,
hint: None,
constraint: None,
table: None,
column: None,
info: DbErrorInfo::default(),
}
}
pub fn detail(mut self, detail: Option<String>) -> Self {
self.detail = detail;
self
}
pub fn hint(mut self, hint: Option<String>) -> Self {
self.hint = hint;
self
}
pub fn constraint(mut self, constraint: Option<String>) -> Self {
self.constraint = constraint;
self
}
pub fn table(mut self, table: Option<String>) -> Self {
self.table = table;
self
}
pub fn column(mut self, column: Option<String>) -> Self {
self.column = column;
self
}
pub fn info(mut self, info: DbErrorInfo) -> Self {
self.info = info;
self
}
}
impl OperatorError {
pub fn new(field: impl Into<String>, operator: impl Into<String>) -> Self {
Self {
field: field.into(),
operator: operator.into(),
}
}
}
impl SearchValueError {
pub fn new(field: impl Into<String>, expected: &'static str) -> Self {
Self {
field: field.into(),
expected,
}
}
}
impl WriteTargetError {
pub fn new(statement: &'static str, source_kind: &'static str) -> Self {
Self {
statement,
source_kind,
}
}
}
impl CteShapeError {
pub fn new(name: impl Into<String>, message: &'static str) -> Self {
Self {
name: name.into(),
message,
}
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
#[error("query returned no rows")]
NotFound,
#[error("unique violation{}", constraint_suffix(&.0.constraint))]
UniqueViolation(Box<ConstraintError>),
#[error("foreign key violation{}", constraint_suffix(&.0.constraint))]
ForeignKeyViolation(Box<ConstraintError>),
#[error("restrict violation{}", constraint_suffix(&.0.constraint))]
RestrictViolation(Box<ConstraintError>),
#[error("not null violation{}", column_suffix(&.0.column))]
NotNullViolation(Box<ColumnError>),
#[error("check violation{}", constraint_suffix(&.0.constraint))]
CheckViolation(Box<ConstraintError>),
#[error("exclusion violation{}", constraint_suffix(&.0.constraint))]
ExclusionViolation(Box<ConstraintError>),
#[error("serialization failure: {}", .0.message)]
SerializationFailure(Box<PgFailure>),
#[error("deadlock detected: {}", .0.message)]
DeadlockDetected(Box<PgFailure>),
#[error("lock not available: {}", .0.message)]
LockNotAvailable(Box<PgFailure>),
#[error("query canceled: {}", .0.message)]
QueryCanceled(Box<PgFailure>),
#[error("insufficient privilege: {}", .0.message)]
InsufficientPrivilege(Box<DatabaseFailure>),
#[error("database error ({}): {}", .0.code, .0.message)]
Database(Box<DatabaseFailure>),
#[error("connection error: {0}")]
Connection(String),
#[error("sqlx error: {0}")]
Sqlx(Box<sqlx::Error>),
#[error("transaction rollback failed after error: {error}; rollback error: {rollback}")]
TransactionRollbackFailed {
error: Box<Error>,
rollback: Box<Error>,
},
#[error("parameter encode error: {0}")]
Encode(String),
#[error("raw SQL fragment has {placeholders} placeholders but {binds} bind values")]
RawBindMismatch {
placeholders: usize,
binds: usize,
},
#[error("operator `{}` is not supported for field `{}`", .0.operator, .0.field)]
InvalidOperator(Box<OperatorError>),
#[error("aggregate modifier `{modifier}` can only be applied to aggregate expressions")]
InvalidAggregateModifier {
modifier: &'static str,
},
#[error("field `{field}` is not sortable")]
InvalidSort {
field: String,
},
#[error("unknown search field `{field}`")]
InvalidSearchField {
field: String,
},
#[error("search field `{field}` is not exposed to JSON requests")]
SearchFieldNotExposed {
field: String,
},
#[error("operator `{}` is not supported for search field `{}`", .0.operator, .0.field)]
InvalidSearchOperator(Box<OperatorError>),
#[error("invalid JSON value for search field `{}`; expected {}", .0.field, .0.expected)]
InvalidSearchValue(Box<SearchValueError>),
#[error("empty search logical expression `{logical}`")]
EmptySearchLogical {
logical: &'static str,
},
#[error("empty logical expression `{logical}`")]
EmptyLogical {
logical: String,
},
#[error("row comparison requires the same arity, got {left} and {right}")]
InvalidRowShape {
left: usize,
right: usize,
},
#[error("{} target must be a table or view source, got {}", .0.statement, .0.source_kind)]
InvalidWriteTarget(Box<WriteTargetError>),
#[error("{statement} statement requires at least one assignment")]
EmptyAssignments {
statement: &'static str,
},
#[error("{statement} statement requires at least one column")]
EmptyColumns {
statement: &'static str,
},
#[error("invalid insert shape: {message}")]
InvalidInsertShape {
message: &'static str,
},
#[error("invalid select shape: {message}")]
InvalidSelectShape {
message: &'static str,
},
#[error("invalid merge shape: {message}")]
InvalidMergeShape {
message: &'static str,
},
#[error("invalid CTE `{}`: {}", .0.name, .0.message)]
InvalidCteShape(Box<CteShapeError>),
#[error("delete without filter is not allowed")]
DeleteWithoutFilter,
#[error("{join} requires an ON condition")]
MissingJoinCondition {
join: &'static str,
},
}
fn constraint_suffix(constraint: &Option<String>) -> String {
constraint
.as_ref()
.map(|name| format!(" on constraint \"{name}\""))
.unwrap_or_default()
}
fn column_suffix(column: &Option<String>) -> String {
column
.as_ref()
.map(|name| format!(" on column \"{name}\""))
.unwrap_or_default()
}
impl From<sqlx::Error> for Error {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => Self::NotFound,
sqlx::Error::Database(db) => Self::from_sqlx_database_error(&*db),
sqlx::Error::Io(err) => Self::Connection(err.to_string()),
sqlx::Error::Tls(err) => Self::Connection(err.to_string()),
sqlx::Error::PoolTimedOut => Self::Connection("database pool timed out".to_owned()),
sqlx::Error::PoolClosed => Self::Connection("database pool is closed".to_owned()),
sqlx::Error::WorkerCrashed => Self::Connection("database worker crashed".to_owned()),
other => Self::Sqlx(Box::new(other)),
}
}
}
impl Error {
pub(crate) fn invalid_operator(field: impl Into<String>, operator: impl Into<String>) -> Self {
Self::InvalidOperator(Box::new(OperatorError::new(field, operator)))
}
pub(crate) fn invalid_search_operator(
field: impl Into<String>,
operator: impl Into<String>,
) -> Self {
Self::InvalidSearchOperator(Box::new(OperatorError::new(field, operator)))
}
pub(crate) fn invalid_search_value(field: impl Into<String>, expected: &'static str) -> Self {
Self::InvalidSearchValue(Box::new(SearchValueError::new(field, expected)))
}
pub(crate) fn invalid_write_target(statement: &'static str, source_kind: &'static str) -> Self {
Self::InvalidWriteTarget(Box::new(WriteTargetError::new(statement, source_kind)))
}
pub(crate) fn invalid_cte_shape(name: impl Into<String>, message: &'static str) -> Self {
Self::InvalidCteShape(Box::new(CteShapeError::new(name, message)))
}
fn from_sqlx_database_error(db: &(dyn DatabaseError + 'static)) -> Self {
let pg = db.try_downcast_ref::<PgDatabaseError>();
let code = db
.code()
.map(|code| code.into_owned())
.unwrap_or_else(|| "unknown".to_owned());
let message = db.message().to_owned();
let detail = pg.and_then(PgDatabaseError::detail).map(ToOwned::to_owned);
let hint = pg.and_then(PgDatabaseError::hint).map(ToOwned::to_owned);
let constraint = db.constraint().map(ToOwned::to_owned);
let table = db.table().map(ToOwned::to_owned);
let column = pg.and_then(PgDatabaseError::column).map(ToOwned::to_owned);
let info = DbErrorInfo::from_sqlx_database_error(db);
match code.as_str() {
"23505" => {
Self::UniqueViolation(Box::new(ConstraintError::new(constraint, detail, info)))
}
"23503" => {
Self::ForeignKeyViolation(Box::new(ConstraintError::new(constraint, detail, info)))
}
"23001" => {
Self::RestrictViolation(Box::new(ConstraintError::new(constraint, detail, info)))
}
"23502" => Self::NotNullViolation(Box::new(ColumnError::new(column, info))),
"23514" => {
Self::CheckViolation(Box::new(ConstraintError::new(constraint, detail, info)))
}
"23P01" => {
Self::ExclusionViolation(Box::new(ConstraintError::new(constraint, detail, info)))
}
"40001" => {
Self::SerializationFailure(Box::new(PgFailure::new(message, detail, hint, info)))
}
"40P01" => {
Self::DeadlockDetected(Box::new(PgFailure::new(message, detail, hint, info)))
}
"55P03" => {
Self::LockNotAvailable(Box::new(PgFailure::new(message, detail, hint, info)))
}
"57014" => Self::QueryCanceled(Box::new(PgFailure::new(message, detail, hint, info))),
"42501" => Self::InsufficientPrivilege(Box::new(
DatabaseFailure::new(code, message)
.detail(detail)
.hint(hint)
.constraint(constraint)
.table(table)
.column(column)
.info(info),
)),
_ => Self::Database(Box::new(
DatabaseFailure::new(code, message)
.detail(detail)
.hint(hint)
.constraint(constraint)
.table(table)
.column(column)
.info(info),
)),
}
}
pub fn is_retryable(&self) -> bool {
self.code().is_some_and(is_retryable_sqlstate) || self.is_connection()
}
pub fn is_connection(&self) -> bool {
matches!(self, Self::Connection(_))
}
pub fn code(&self) -> Option<&str> {
match self {
Self::UniqueViolation(_) => Some("23505"),
Self::ForeignKeyViolation(_) => Some("23503"),
Self::RestrictViolation(_) => Some("23001"),
Self::NotNullViolation(_) => Some("23502"),
Self::CheckViolation(_) => Some("23514"),
Self::ExclusionViolation(_) => Some("23P01"),
Self::SerializationFailure(_) => Some("40001"),
Self::DeadlockDetected(_) => Some("40P01"),
Self::LockNotAvailable(_) => Some("55P03"),
Self::InsufficientPrivilege(_) => Some("42501"),
Self::QueryCanceled(_) => Some("57014"),
Self::Database(err) => Some(&err.code),
_ => None,
}
}
pub fn constraint_name(&self) -> Option<&str> {
match self {
Self::UniqueViolation(err)
| Self::ForeignKeyViolation(err)
| Self::RestrictViolation(err)
| Self::CheckViolation(err)
| Self::ExclusionViolation(err) => {
err.constraint.as_deref().or(err.info.constraint.as_deref())
}
Self::InsufficientPrivilege(err) | Self::Database(err) => {
err.constraint.as_deref().or(err.info.constraint.as_deref())
}
_ => self
.db_error_info()
.and_then(|info| info.constraint.as_deref()),
}
}
pub fn column_name(&self) -> Option<&str> {
match self {
Self::NotNullViolation(err) => err.column.as_deref().or(err.info.column.as_deref()),
Self::InsufficientPrivilege(err) | Self::Database(err) => {
err.column.as_deref().or(err.info.column.as_deref())
}
_ => self.db_error_info().and_then(|info| info.column.as_deref()),
}
}
pub fn detail(&self) -> Option<&str> {
match self {
Self::UniqueViolation(err)
| Self::ForeignKeyViolation(err)
| Self::RestrictViolation(err)
| Self::CheckViolation(err)
| Self::ExclusionViolation(err) => err.detail.as_deref(),
Self::SerializationFailure(err)
| Self::DeadlockDetected(err)
| Self::LockNotAvailable(err)
| Self::QueryCanceled(err) => err.detail.as_deref(),
Self::InsufficientPrivilege(err) | Self::Database(err) => err.detail.as_deref(),
_ => None,
}
}
pub fn hint(&self) -> Option<&str> {
match self {
Self::SerializationFailure(err)
| Self::DeadlockDetected(err)
| Self::LockNotAvailable(err)
| Self::QueryCanceled(err) => err.hint.as_deref(),
Self::InsufficientPrivilege(err) | Self::Database(err) => err.hint.as_deref(),
_ => None,
}
}
fn db_error_info(&self) -> Option<&DbErrorInfo> {
match self {
Self::UniqueViolation(err)
| Self::ForeignKeyViolation(err)
| Self::RestrictViolation(err)
| Self::CheckViolation(err)
| Self::ExclusionViolation(err) => Some(&err.info),
Self::NotNullViolation(err) => Some(&err.info),
Self::SerializationFailure(err)
| Self::DeadlockDetected(err)
| Self::LockNotAvailable(err)
| Self::QueryCanceled(err) => Some(&err.info),
Self::InsufficientPrivilege(err) | Self::Database(err) => Some(&err.info),
_ => None,
}
}
}
fn is_retryable_sqlstate(code: &str) -> bool {
matches!(code, "40001" | "40P01" | "57P01" | "57P02" | "57P03")
}
#[cfg(test)]
mod tests {
use super::{
ColumnError, ConstraintError, DatabaseFailure, DbErrorInfo, DbErrorPosition, Error,
PgFailure,
};
#[test]
fn error_size_stays_small_for_application_results() {
assert!(std::mem::size_of::<Error>() <= 32);
}
#[test]
fn structured_error_helpers_expose_common_api_boundary_fields() {
let error = Error::Database(Box::new(DatabaseFailure {
code: "23505".to_owned(),
message: "duplicate key value violates unique constraint".to_owned(),
detail: Some("Key (email)=(ada@example.com) already exists.".to_owned()),
hint: Some("Use another email.".to_owned()),
constraint: Some("users_email_key".to_owned()),
table: Some("users".to_owned()),
column: Some("email".to_owned()),
info: DbErrorInfo {
schema: Some("public".to_owned()),
datatype: Some("text".to_owned()),
where_: Some("SQL statement".to_owned()),
position: Some(DbErrorPosition::Original(42)),
..DbErrorInfo::default()
},
}));
assert_eq!(error.code(), Some("23505"));
assert_eq!(error.constraint_name(), Some("users_email_key"));
assert_eq!(error.column_name(), Some("email"));
assert_eq!(
error.detail(),
Some("Key (email)=(ada@example.com) already exists.")
);
assert_eq!(error.hint(), Some("Use another email."));
let Error::Database(err) = error else {
panic!("expected database error");
};
assert_eq!(err.table.as_deref(), Some("users"));
assert_eq!(err.info.schema.as_deref(), Some("public"));
assert_eq!(err.info.datatype.as_deref(), Some("text"));
assert_eq!(err.info.where_.as_deref(), Some("SQL statement"));
assert_eq!(err.info.position, Some(DbErrorPosition::Original(42)));
}
#[test]
fn specialized_error_helpers_fall_back_to_db_error_info() {
let error = Error::UniqueViolation(Box::new(ConstraintError {
constraint: None,
detail: None,
info: DbErrorInfo {
constraint: Some("users_email_key".to_owned()),
table: Some("users".to_owned()),
column: Some("email".to_owned()),
..DbErrorInfo::default()
},
}));
assert_eq!(error.code(), Some("23505"));
assert_eq!(error.constraint_name(), Some("users_email_key"));
assert_eq!(error.column_name(), Some("email"));
}
#[test]
fn retryable_errors_include_transaction_shutdown_and_connection_failures() {
assert!(
Error::SerializationFailure(Box::new(PgFailure {
message: "could not serialize access".to_owned(),
detail: None,
hint: None,
info: DbErrorInfo::default(),
}))
.is_retryable()
);
assert!(
Error::DeadlockDetected(Box::new(PgFailure {
message: "deadlock detected".to_owned(),
detail: None,
hint: None,
info: DbErrorInfo::default(),
}))
.is_retryable()
);
assert!(Error::Connection("connection closed".to_owned()).is_retryable());
for code in ["57P01", "57P02", "57P03"] {
assert!(
Error::Database(Box::new(DatabaseFailure::new(
code,
"transient server failure"
)))
.is_retryable(),
"{code} should be retryable"
);
}
assert!(
!Error::QueryCanceled(Box::new(PgFailure {
message: "canceling statement due to user request".to_owned(),
detail: None,
hint: None,
info: DbErrorInfo::default(),
}))
.is_retryable()
);
assert!(
!Error::NotNullViolation(Box::new(ColumnError {
column: Some("email".to_owned()),
info: DbErrorInfo::default(),
}))
.is_retryable()
);
}
#[test]
fn lock_not_available_exposes_sqlstate_without_global_retry_policy() {
let error = Error::LockNotAvailable(Box::new(PgFailure {
message: "could not obtain lock on row in relation".to_owned(),
detail: Some("row is already locked".to_owned()),
hint: Some("retry later".to_owned()),
info: DbErrorInfo::default(),
}));
assert_eq!(error.code(), Some("55P03"));
assert_eq!(error.detail(), Some("row is already locked"));
assert_eq!(error.hint(), Some("retry later"));
assert!(!error.is_retryable());
}
#[test]
fn connection_like_sqlx_errors_map_to_connection_variant() {
let pool_timed_out = Error::from(sqlx::Error::PoolTimedOut);
assert!(matches!(pool_timed_out, Error::Connection(_)));
assert!(pool_timed_out.is_retryable());
let io = Error::from(sqlx::Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"connection reset",
)));
assert!(matches!(io, Error::Connection(_)));
assert!(io.is_retryable());
}
}