use std::borrow::Cow;
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub enum EngineOnly {}
impl std::fmt::Display for EngineOnly {
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {}
}
}
impl std::error::Error for EngineOnly {}
pub trait DurableError:
serde::Serialize + serde::de::DeserializeOwned + std::error::Error + 'static
{
}
impl<E> DurableError for E where
E: serde::Serialize + serde::de::DeserializeOwned + std::error::Error + 'static
{
}
pub type Result<T, E = EngineOnly> = std::result::Result<T, Error<E>>;
#[derive(Debug, thiserror::Error, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum Error<E = EngineOnly> {
#[error(transparent)]
Application(E),
#[error("cannot {operation} before DBOS is launched")]
NotLaunched {
operation: Cow<'static, str>,
},
#[error("cannot {operation} after DBOS is launched")]
AlreadyLaunched {
operation: Cow<'static, str>,
},
#[error("{operation} must be called from within a workflow")]
NotInWorkflow {
operation: Cow<'static, str>,
},
#[error("{operation} cannot be called from within a step")]
InsideStep {
operation: Cow<'static, str>,
},
#[error(
"{operation} was called on a different DBOS instance than the one running this workflow"
)]
WrongInstance {
operation: Cow<'static, str>,
},
#[error("invalid configuration: {0}")]
Config(String),
#[error("invalid argument to {operation}: {detail}")]
InvalidArgument {
operation: Cow<'static, str>,
detail: String,
},
#[error("a workflow is already registered as {key}")]
AlreadyRegistered {
key: String,
},
#[error("could not serialize the workflow {what}: {message}")]
Serialization {
what: Cow<'static, str>,
message: String,
#[serde(skip)]
#[source]
source: Option<serde_json::Error>,
},
#[error("could not deserialize the workflow {what}: {message}")]
Deserialization {
what: Cow<'static, str>,
message: String,
#[serde(skip)]
#[source]
source: Option<serde_json::Error>,
},
#[error("no workflow is registered as {key}")]
NotRegistered {
key: String,
},
#[error("no workflow exists with id {workflow_id}")]
WorkflowNotFound {
workflow_id: String,
},
#[error("the workflow {workflow_id} was interrupted by shutdown and left PENDING")]
Interrupted {
workflow_id: String,
},
#[error("the workflow {workflow_id} failed: {message}")]
WorkflowFailed {
workflow_id: String,
message: String,
},
#[error("the workflow {workflow_id} was cancelled")]
WorkflowCancelled {
workflow_id: String,
},
#[error("the workflow {workflow_id} this one was awaiting was cancelled")]
AwaitedWorkflowCancelled {
workflow_id: String,
},
#[error("the workflow {workflow_id} exceeded {recovery_attempts} recovery attempts")]
MaxRecoveryAttemptsExceeded {
workflow_id: String,
recovery_attempts: i64,
},
#[error("the step {step} failed: {message}")]
StepFailed {
step: String,
message: String,
},
#[error(
"step {step} was built {built} but polled {polled}: a step takes its id where it is built"
)]
StepBuiltElsewhere {
step: String,
built: std::borrow::Cow<'static, str>,
polled: std::borrow::Cow<'static, str>,
},
#[error("the step {step} exceeded its {}ms timeout", timeout.as_millis())]
StepTimeout {
step: String,
timeout: std::time::Duration,
},
#[error("the step {step} failed on all {attempts} attempts")]
MaxStepRetriesExceeded {
step: String,
attempts: u32,
errors: Vec<Error<E>>,
},
#[error(transparent)]
SystemDatabase(crate::sysdb::Error),
}
impl<E> From<E> for Error<E> {
fn from(error: E) -> Self {
Error::Application(error)
}
}
impl<E> Error<E> {
pub(crate) fn map_application<E2>(self, f: impl Fn(E) -> E2 + Copy) -> Error<E2> {
match self {
Error::Application(error) => Error::Application(f(error)),
Error::NotLaunched { operation } => Error::NotLaunched { operation },
Error::AlreadyLaunched { operation } => Error::AlreadyLaunched { operation },
Error::NotInWorkflow { operation } => Error::NotInWorkflow { operation },
Error::InsideStep { operation } => Error::InsideStep { operation },
Error::WrongInstance { operation } => Error::WrongInstance { operation },
Error::Config(message) => Error::Config(message),
Error::InvalidArgument { operation, detail } => {
Error::InvalidArgument { operation, detail }
}
Error::AlreadyRegistered { key } => Error::AlreadyRegistered { key },
Error::Serialization {
what,
message,
source,
} => Error::Serialization {
what,
message,
source,
},
Error::Deserialization {
what,
message,
source,
} => Error::Deserialization {
what,
message,
source,
},
Error::NotRegistered { key } => Error::NotRegistered { key },
Error::WorkflowNotFound { workflow_id } => Error::WorkflowNotFound { workflow_id },
Error::Interrupted { workflow_id } => Error::Interrupted { workflow_id },
Error::WorkflowFailed {
workflow_id,
message,
} => Error::WorkflowFailed {
workflow_id,
message,
},
Error::WorkflowCancelled { workflow_id } => Error::WorkflowCancelled { workflow_id },
Error::AwaitedWorkflowCancelled { workflow_id } => {
Error::AwaitedWorkflowCancelled { workflow_id }
}
Error::MaxRecoveryAttemptsExceeded {
workflow_id,
recovery_attempts,
} => Error::MaxRecoveryAttemptsExceeded {
workflow_id,
recovery_attempts,
},
Error::StepFailed { step, message } => Error::StepFailed { step, message },
Error::StepTimeout { step, timeout } => Error::StepTimeout { step, timeout },
Error::StepBuiltElsewhere {
step,
built,
polled,
} => Error::StepBuiltElsewhere {
step,
built,
polled,
},
Error::MaxStepRetriesExceeded {
step,
attempts,
errors,
} => Error::MaxStepRetriesExceeded {
step,
attempts,
errors: errors
.into_iter()
.map(|error| error.map_application(f))
.collect(),
},
Error::SystemDatabase(error) => Error::SystemDatabase(error),
}
}
pub(crate) fn control(&self) -> Option<Error<EngineOnly>> {
match self {
Error::WorkflowCancelled { workflow_id } => Some(Error::WorkflowCancelled {
workflow_id: workflow_id.clone(),
}),
Error::Interrupted { workflow_id } => Some(Error::Interrupted {
workflow_id: workflow_id.clone(),
}),
Error::SystemDatabase(error) => Some(Error::SystemDatabase(error.clone())),
_ => None,
}
}
}
impl Error<EngineOnly> {
pub fn lift<E>(self) -> Error<E> {
self.map_application(|impossible| match impossible {})
}
}
#[derive(Debug)]
pub(crate) enum Failure {
Recorded(String),
Control(Error<EngineOnly>),
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, thiserror::Error, serde::Serialize, serde::Deserialize, PartialEq)]
#[error("the card was declined after {attempts} attempts")]
struct CardDeclined {
attempts: u32,
}
#[test]
fn an_application_error_round_trips_whole() {
let error: Error<CardDeclined> = CardDeclined { attempts: 3 }.into();
let json = serde_json::to_string(&error).unwrap();
let back: Error<CardDeclined> = serde_json::from_str(&json).unwrap();
let Error::Application(back) = back else {
panic!("expected an application error, got {back:?}")
};
assert_eq!(back, CardDeclined { attempts: 3 }, "the field survives");
assert_eq!(error.to_string(), back.to_string());
}
#[test]
fn the_blanket_conversion_lifts_an_application_error() {
fn fallible() -> Result<(), CardDeclined> {
Err(CardDeclined { attempts: 1 })?
}
assert!(matches!(
fallible().unwrap_err(),
Error::Application(CardDeclined { attempts: 1 })
));
}
#[test]
fn an_engine_error_lifts_into_any_channel() {
let engine: Error = Error::NotLaunched {
operation: "run a workflow".into(),
};
let lifted: Error<CardDeclined> = engine.lift();
assert_eq!(
lifted.to_string(),
"cannot run a workflow before DBOS is launched"
);
}
#[test]
fn only_control_errors_report_themselves_as_control() {
let cancelled: Error<CardDeclined> = Error::WorkflowCancelled {
workflow_id: "wf-1".to_owned(),
};
assert!(cancelled.control().is_some());
let failed: Error<CardDeclined> = CardDeclined { attempts: 1 }.into();
assert!(
failed.control().is_none(),
"an application failure is an outcome, not a signal"
);
}
}