use crate::serialize::PortableWorkflowError;
use thiserror::Error;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorCode {
Database,
Initialization,
Serialization,
WorkflowNotRegistered,
QueueNotRegistered,
NonExistentWorkflow,
QueueDeduplicated,
WorkflowCancelled,
ConflictingRegistration,
Timeout,
UnexpectedStep,
Application,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("migration error: {0}")]
Migrate(#[from] sqlx::migrate::MigrateError),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("serialization format error: {0}")]
Serialization(String),
#[error("no workflow registered under name `{0}`")]
UnknownWorkflow(String),
#[error("no queue registered under name `{0}`")]
UnknownQueue(String),
#[error("workflow `{0}` does not exist")]
NonExistentWorkflow(String),
#[error("deduplication id `{dedup_id}` already in use on queue `{queue_name}`")]
QueueDeduplicated {
queue_name: String,
dedup_id: String,
},
#[error("workflow `{0}` was cancelled")]
Cancelled(String),
#[error("workflow name `{0}` is registered more than once")]
ConflictingRegistration(String),
#[error("operation timed out")]
Timeout,
#[error(
"workflow `{workflow_id}` step {step_id} is `{expected}` but `{recorded}` \
is recorded there — the workflow function is non-deterministic"
)]
UnexpectedStep {
workflow_id: String,
step_id: i32,
expected: String,
recorded: String,
},
#[error("{message}")]
App {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
},
#[error("{}", .0.message)]
Portable(PortableWorkflowError),
}
impl Error {
pub fn app(msg: impl Into<String>) -> Self {
Error::App {
message: msg.into(),
source: None,
}
}
pub fn app_source(
msg: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Error::App {
message: msg.into(),
source: Some(Box::new(source)),
}
}
pub fn portable(name: impl Into<String>, message: impl Into<String>) -> Self {
Error::Portable(PortableWorkflowError {
name: name.into(),
message: message.into(),
code: None,
data: None,
})
}
pub fn nonexistent_workflow(id: impl Into<String>) -> Self {
Error::NonExistentWorkflow(id.into())
}
pub fn conflicting_registration(name: impl Into<String>) -> Self {
Error::ConflictingRegistration(name.into())
}
pub fn queue_deduplicated(queue_name: impl Into<String>, dedup_id: impl Into<String>) -> Self {
Error::QueueDeduplicated {
queue_name: queue_name.into(),
dedup_id: dedup_id.into(),
}
}
pub fn unexpected_step(
workflow_id: impl Into<String>,
step_id: i32,
expected: impl Into<String>,
recorded: impl Into<String>,
) -> Self {
Error::UnexpectedStep {
workflow_id: workflow_id.into(),
step_id,
expected: expected.into(),
recorded: recorded.into(),
}
}
pub fn code(&self) -> ErrorCode {
match self {
Error::Db(_) => ErrorCode::Database,
Error::Migrate(_) => ErrorCode::Initialization,
Error::Serde(_) | Error::Serialization(_) => ErrorCode::Serialization,
Error::UnknownWorkflow(_) => ErrorCode::WorkflowNotRegistered,
Error::UnknownQueue(_) => ErrorCode::QueueNotRegistered,
Error::NonExistentWorkflow(_) => ErrorCode::NonExistentWorkflow,
Error::QueueDeduplicated { .. } => ErrorCode::QueueDeduplicated,
Error::Cancelled(_) => ErrorCode::WorkflowCancelled,
Error::ConflictingRegistration(_) => ErrorCode::ConflictingRegistration,
Error::Timeout => ErrorCode::Timeout,
Error::UnexpectedStep { .. } => ErrorCode::UnexpectedStep,
Error::App { .. } | Error::Portable(_) => ErrorCode::Application,
}
}
pub fn is_unique_violation(&self) -> bool {
matches!(self, Error::Db(sqlx::Error::Database(e)) if e.is_unique_violation())
}
pub fn is_foreign_key_violation(&self) -> bool {
matches!(self, Error::Db(sqlx::Error::Database(e)) if e.is_foreign_key_violation())
}
pub fn is_retryable(&self) -> bool {
let Error::Db(e) = self else { return false };
match e {
sqlx::Error::Io(_) | sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => true,
sqlx::Error::Database(db) => {
db.code().map(|c| is_retryable_db_code(&c)).unwrap_or(false)
}
_ => false,
}
}
pub fn is_tx_conflict(&self) -> bool {
let Error::Db(sqlx::Error::Database(db)) = self else {
return false;
};
db.code().map(|c| is_tx_conflict_code(&c)).unwrap_or(false)
}
}
fn is_tx_conflict_code(code: &str) -> bool {
if code.len() == 5 {
matches!(code, "40001" | "40P01")
} else {
code.parse::<i32>().is_ok_and(|n| matches!(n & 0xFF, 5 | 6))
}
}
fn is_retryable_db_code(code: &str) -> bool {
if code.len() == 5 {
return code.starts_with("08") || matches!(code, "57P01" | "57P02" | "57P03" | "53300");
}
code.parse::<i32>().is_ok_and(|n| matches!(n & 0xFF, 5 | 6))
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_map_to_variants() {
assert_eq!(Error::app("x").code(), ErrorCode::Application);
assert_eq!(Error::Timeout.code(), ErrorCode::Timeout);
assert_eq!(
Error::nonexistent_workflow("wf").code(),
ErrorCode::NonExistentWorkflow
);
assert_eq!(
Error::queue_deduplicated("q", "d").code(),
ErrorCode::QueueDeduplicated
);
assert_eq!(
Error::UnknownWorkflow("n".into()).code(),
ErrorCode::WorkflowNotRegistered
);
assert_eq!(
Error::unexpected_step("wf", 3, "new", "old").code(),
ErrorCode::UnexpectedStep
);
}
#[test]
fn non_db_errors_are_not_retryable_or_violations() {
let e = Error::app("boom");
assert!(!e.is_retryable());
assert!(!e.is_unique_violation());
assert!(!e.is_foreign_key_violation());
}
#[test]
fn app_source_exposes_the_cause_chain() {
use std::error::Error as _;
let cause = std::io::Error::other("disk gone");
let err = Error::app_source("save failed", cause);
assert_eq!(err.to_string(), "save failed");
assert_eq!(err.code(), ErrorCode::Application);
assert_eq!(
err.source().expect("app_source sets a source").to_string(),
"disk gone"
);
assert!(Error::app("x").source().is_none());
}
#[test]
fn db_code_classification() {
assert!(is_retryable_db_code("08006")); assert!(is_retryable_db_code("57P01")); assert!(is_retryable_db_code("5")); assert!(is_retryable_db_code("261")); assert!(!is_retryable_db_code("23505")); assert!(!is_retryable_db_code("40001")); }
#[test]
fn tx_conflict_classification() {
assert!(is_tx_conflict_code("40001")); assert!(is_tx_conflict_code("40P01")); assert!(is_tx_conflict_code("5")); assert!(is_tx_conflict_code("6")); assert!(!is_tx_conflict_code("23505")); assert!(!is_tx_conflict_code("08006")); }
}