use serde_json::{json, Value};
use std::fmt;
#[derive(Debug, Clone)]
pub struct ErrorFields {
pub message: String,
pub task: String,
pub instance: String,
pub status: Option<Value>,
pub title: Option<String>,
pub detail: Option<String>,
pub original_type: Option<String>,
pub retry_count: u32,
pub timestamp: Option<String>,
}
impl ErrorFields {
fn new(
message: impl Into<String>,
task: impl Into<String>,
instance: impl Into<String>,
) -> Self {
Self {
message: message.into(),
task: task.into(),
instance: instance.into(),
status: None,
title: None,
detail: None,
original_type: None,
retry_count: 0,
timestamp: None,
}
}
fn with_status(mut self, status: Option<Value>) -> Self {
self.status = status;
self
}
fn with_title(mut self, title: Option<String>) -> Self {
self.title = title;
self
}
fn with_detail(mut self, detail: Option<String>) -> Self {
self.detail = detail;
self
}
fn with_original_type(mut self, original_type: Option<String>) -> Self {
self.original_type = original_type;
self
}
fn instance_opt(&self) -> Option<&str> {
if self.instance.is_empty() {
None
} else {
Some(&self.instance)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
Validation,
Expression,
Runtime,
Timeout,
Communication,
Authentication,
Authorization,
Configuration,
WorkflowEnd,
}
impl ErrorKind {
pub fn as_str(&self) -> &'static str {
match self {
ErrorKind::Validation => "validation",
ErrorKind::Expression => "expression",
ErrorKind::Runtime => "runtime",
ErrorKind::Timeout => "timeout",
ErrorKind::Communication => "communication",
ErrorKind::Authentication => "authentication",
ErrorKind::Authorization => "authorization",
ErrorKind::Configuration => "configuration",
ErrorKind::WorkflowEnd => "workflow-end",
}
}
pub fn type_uri(&self) -> &'static str {
match self {
ErrorKind::Validation => "https://serverlessworkflow.io/spec/1.0.0/errors/validation",
ErrorKind::Expression => "https://serverlessworkflow.io/spec/1.0.0/errors/expression",
ErrorKind::Runtime => "https://serverlessworkflow.io/spec/1.0.0/errors/runtime",
ErrorKind::Timeout => "https://serverlessworkflow.io/spec/1.0.0/errors/timeout",
ErrorKind::Communication => {
"https://serverlessworkflow.io/spec/1.0.0/errors/communication"
}
ErrorKind::Authentication => {
"https://serverlessworkflow.io/spec/1.0.0/errors/authentication"
}
ErrorKind::Authorization => {
"https://serverlessworkflow.io/spec/1.0.0/errors/authorization"
}
ErrorKind::Configuration => {
"https://serverlessworkflow.io/spec/1.0.0/errors/configuration"
}
ErrorKind::WorkflowEnd => {
"https://serverlessworkflow.io/spec/1.0.0/errors/workflow-end"
}
}
}
pub fn from_type_str(error_type: &str) -> Self {
const TYPE_MAP: &[(&str, ErrorKind)] = &[
("validation", ErrorKind::Validation),
("expression", ErrorKind::Expression),
("timeout", ErrorKind::Timeout),
("communication", ErrorKind::Communication),
("authentication", ErrorKind::Authentication),
("authorization", ErrorKind::Authorization),
("configuration", ErrorKind::Configuration),
];
TYPE_MAP
.iter()
.find(|(suffix, _)| {
error_type.ends_with(suffix)
&& (error_type.len() == suffix.len()
|| error_type
.as_bytes()
.get(error_type.len() - suffix.len() - 1)
== Some(&b'/'))
})
.map(|(_, kind)| *kind)
.unwrap_or(ErrorKind::Runtime)
}
}
#[derive(Debug, Clone)]
pub struct WorkflowError {
kind: ErrorKind,
fields: ErrorFields,
end_output: Option<Value>,
}
impl std::error::Error for WorkflowError {}
impl fmt::Display for WorkflowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} error in task '{}': {}",
self.kind.as_str(),
self.fields.task,
self.fields.message
)
}
}
impl WorkflowError {
pub fn kind(&self) -> ErrorKind {
self.kind
}
pub fn fields(&self) -> &ErrorFields {
&self.fields
}
pub fn validation(message: impl Into<String>, task: impl Into<String>) -> Self {
Self {
kind: ErrorKind::Validation,
fields: ErrorFields::new(message, task, ""),
end_output: None,
}
}
pub fn expression(message: impl Into<String>, task: impl Into<String>) -> Self {
Self {
kind: ErrorKind::Expression,
fields: ErrorFields::new(message, task, ""),
end_output: None,
}
}
pub fn runtime(
message: impl Into<String>,
task: impl Into<String>,
instance: impl Into<String>,
) -> Self {
Self {
kind: ErrorKind::Runtime,
fields: ErrorFields::new(message, task, instance),
end_output: None,
}
}
pub fn runtime_simple(message: impl Into<String>, task: impl Into<String>) -> Self {
Self::runtime(message, task, "")
}
pub fn workflow_end(task: impl Into<String>, output: Value) -> Self {
Self {
kind: ErrorKind::WorkflowEnd,
fields: ErrorFields::new("workflow ended via 'then: end' directive", task, ""),
end_output: Some(output),
}
}
pub fn end_output(&self) -> Option<&Value> {
self.end_output.as_ref()
}
pub fn is_workflow_end(&self) -> bool {
self.kind == ErrorKind::WorkflowEnd
}
pub fn timeout(message: impl Into<String>, task: impl Into<String>) -> Self {
Self {
kind: ErrorKind::Timeout,
fields: ErrorFields::new(message, task, "").with_status(Some(json!(408))),
end_output: None,
}
}
pub fn communication(message: impl Into<String>, task: impl Into<String>) -> Self {
Self {
kind: ErrorKind::Communication,
fields: ErrorFields::new(message, task, ""),
end_output: None,
}
}
pub fn communication_with_status(
message: impl Into<String>,
task: impl Into<String>,
status_code: u16,
) -> Self {
Self {
kind: ErrorKind::Communication,
fields: ErrorFields::new(message, task, "").with_status(Some(Value::from(status_code))),
end_output: None,
}
}
pub fn typed(
error_type: &str,
detail: String,
task: String,
instance: String,
status: Option<Value>,
title: Option<String>,
) -> Self {
let details = if detail.is_empty() {
None
} else {
Some(detail)
};
let fields = ErrorFields::new(details.clone().unwrap_or_default(), task, instance)
.with_status(status)
.with_title(title)
.with_detail(details)
.with_original_type(Some(error_type.to_string()));
let kind = ErrorKind::from_type_str(error_type);
Self {
kind,
fields,
end_output: None,
}
}
pub fn error_type(&self) -> &str {
self.fields
.original_type
.as_deref()
.unwrap_or(self.kind.type_uri())
}
pub fn error_type_short(&self) -> &str {
if let Some(ot) = &self.fields.original_type {
if let Some(short) = ot.rsplit('/').next() {
return short;
}
}
self.kind.as_str()
}
pub fn task(&self) -> &str {
&self.fields.task
}
pub fn instance(&self) -> Option<&str> {
self.fields.instance_opt()
}
pub fn status(&self) -> Option<&Value> {
self.fields.status.as_ref()
}
pub fn title(&self) -> Option<&str> {
self.fields.title.as_deref()
}
pub fn detail(&self) -> Option<&str> {
self.fields.detail.as_deref()
}
pub fn to_value(&self) -> Value {
let mut map = serde_json::Map::new();
map.insert(
"type".to_string(),
Value::String(self.error_type().to_string()),
);
if let Some(status) = self.status() {
map.insert("status".to_string(), status.clone());
}
if let Some(title) = self.title() {
map.insert("title".to_string(), Value::String(title.to_string()));
}
if let Some(detail) = self.detail() {
map.insert("detail".to_string(), Value::String(detail.to_string()));
}
if let Some(instance) = self.instance() {
map.insert("instance".to_string(), Value::String(instance.to_string()));
}
let mut swf = serde_json::Map::new();
swf.insert(
"kind".to_string(),
Value::String(self.kind.as_str().to_string()),
);
swf.insert(
"retryCount".to_string(),
Value::from(self.fields.retry_count),
);
let ts = match &self.fields.timestamp {
Some(ts) => ts.clone(),
None => chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
};
swf.insert("timestamp".to_string(), Value::String(ts));
map.insert("swf".to_string(), Value::Object(swf));
Value::Object(map)
}
pub fn with_instance(self, instance: impl Into<String>) -> Self {
let new_instance = instance.into();
let inst = if self.fields.instance.is_empty() || self.fields.instance == "/" {
new_instance
} else {
self.fields.instance.clone()
};
Self {
kind: self.kind,
fields: ErrorFields {
message: self.fields.message,
task: self.fields.task,
instance: inst,
status: self.fields.status,
title: self.fields.title,
detail: self.fields.detail,
original_type: self.fields.original_type,
retry_count: self.fields.retry_count,
timestamp: self.fields.timestamp,
},
end_output: self.end_output,
}
}
pub fn with_retry_count(self, count: u32) -> Self {
Self {
kind: self.kind,
fields: ErrorFields {
retry_count: count,
..self.fields
},
end_output: self.end_output,
}
}
pub fn with_timestamp(self, timestamp: impl Into<String>) -> Self {
Self {
kind: self.kind,
fields: ErrorFields {
timestamp: Some(timestamp.into()),
..self.fields
},
end_output: self.end_output,
}
}
}
pub type WorkflowResult<T> = Result<T, WorkflowError>;
pub fn serialize_to_value<T: serde::Serialize>(
value: &T,
label: &str,
task_name: &str,
) -> WorkflowResult<Value> {
serde_json::to_value(value).map_err(|e| {
WorkflowError::runtime(
format!("failed to serialize {}: {}", label, e),
task_name,
"",
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_type_validation() {
let err = WorkflowError::validation("invalid input", "task1");
assert_eq!(err.error_type_short(), "validation");
assert!(err.error_type().ends_with("/validation"));
assert_eq!(err.task(), "task1");
}
#[test]
fn test_error_type_expression() {
let err = WorkflowError::expression("bad jq", "task2");
assert_eq!(err.error_type_short(), "expression");
}
#[test]
fn test_error_type_runtime() {
let err = WorkflowError::runtime("something failed", "task3", "/ref");
assert_eq!(err.error_type_short(), "runtime");
assert_eq!(err.instance(), Some("/ref"));
}
#[test]
fn test_error_type_timeout() {
let err = WorkflowError::timeout("timed out", "task4");
assert_eq!(err.error_type_short(), "timeout");
assert!(err.instance().is_none());
}
#[test]
fn test_error_type_communication() {
let err = WorkflowError::communication("connection refused", "task5");
assert_eq!(err.error_type_short(), "communication");
}
#[test]
fn test_error_with_instance() {
let err = WorkflowError::runtime("invalid", "task1", "").with_instance("/ref/task1");
assert_eq!(err.error_type_short(), "runtime");
assert_eq!(err.instance(), Some("/ref/task1"));
}
#[test]
fn test_error_with_instance_preserves_type() {
let err = WorkflowError::timeout("timed out", "task1").with_instance("/ref/task1");
assert_eq!(err.error_type_short(), "timeout");
assert_eq!(err.instance(), Some("/ref/task1"));
}
#[test]
fn test_error_task_name() {
let err = WorkflowError::timeout("timeout", "myTask");
assert_eq!(err.task(), "myTask");
}
#[test]
fn test_error_display() {
let err = WorkflowError::validation("bad input", "task1");
let msg = format!("{}", err);
assert!(msg.contains("bad input"));
assert!(msg.contains("task1"));
}
#[test]
fn test_error_typed_with_status() {
let err = WorkflowError::typed(
"https://serverlessworkflow.io/spec/1.0.0/errors/transient",
"Something went wrong".to_string(),
"testTask".to_string(),
"/do/0/testTask".to_string(),
Some(Value::from(503)),
Some("Transient Error".to_string()),
);
assert_eq!(err.error_type_short(), "transient");
assert_eq!(err.status(), Some(&Value::from(503)));
assert_eq!(err.title(), Some("Transient Error"));
assert_eq!(err.detail(), Some("Something went wrong"));
}
#[test]
fn test_error_to_value() {
let err = WorkflowError::typed(
"https://serverlessworkflow.io/spec/1.0.0/errors/authentication",
"Auth failed".to_string(),
"authTask".to_string(),
"".to_string(),
Some(Value::from(401)),
Some("Auth Error".to_string()),
);
let val = err.to_value();
assert_eq!(
val["type"],
"https://serverlessworkflow.io/spec/1.0.0/errors/authentication"
);
assert_eq!(val["status"], 401);
assert_eq!(val["title"], "Auth Error");
assert_eq!(val["detail"], "Auth failed");
}
#[test]
fn test_error_kind() {
let err = WorkflowError::timeout("timed out", "task1");
assert_eq!(err.kind(), ErrorKind::Timeout);
}
#[test]
fn test_error_to_value_swf_namespace() {
let err = WorkflowError::communication_with_status("connection refused", "task1", 503);
let val = err.to_value();
assert_eq!(val["swf"]["kind"], "communication");
assert_eq!(val["swf"]["retryCount"], 0);
let ts = val["swf"]["timestamp"].as_str().unwrap();
assert!(ts.contains("T"), "timestamp should be ISO 8601: {}", ts);
}
#[test]
fn test_error_with_retry_count() {
let err = WorkflowError::timeout("timed out", "task1").with_retry_count(3);
let val = err.to_value();
assert_eq!(val["swf"]["kind"], "timeout");
assert_eq!(val["swf"]["retryCount"], 3);
}
#[test]
fn test_error_with_timestamp() {
let err =
WorkflowError::runtime("fail", "task1", "/").with_timestamp("2026-05-11T10:30:00.000Z");
let val = err.to_value();
assert_eq!(val["swf"]["timestamp"], "2026-05-11T10:30:00.000Z");
}
#[test]
fn test_error_swf_kind_variants() {
assert_eq!(
WorkflowError::validation("v", "t").to_value()["swf"]["kind"],
"validation"
);
assert_eq!(
WorkflowError::expression("e", "t").to_value()["swf"]["kind"],
"expression"
);
assert_eq!(
WorkflowError::runtime("r", "t", "/").to_value()["swf"]["kind"],
"runtime"
);
assert_eq!(
WorkflowError::timeout("t", "t").to_value()["swf"]["kind"],
"timeout"
);
assert_eq!(
WorkflowError::typed(
"https://serverlessworkflow.io/spec/1.0.0/errors/authentication",
"a".to_string(),
"t".to_string(),
"t".to_string(),
None,
None,
)
.to_value()["swf"]["kind"],
"authentication"
);
}
}