#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCategory {
#[default]
Unknown,
Transient,
Permanent,
}
impl ErrorCategory {
pub fn as_str(&self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Transient => "transient",
Self::Permanent => "permanent",
}
}
pub fn parse(s: &str) -> Self {
match s {
"transient" => Self::Transient,
"permanent" => Self::Permanent,
"business" => Self::Permanent,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ErrorSeverity {
Info,
Warning,
#[default]
Error,
Critical,
}
impl ErrorSeverity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
Self::Critical => "critical",
}
}
pub fn parse(s: &str) -> Self {
match s {
"info" => Self::Info,
"warning" => Self::Warning,
"critical" => Self::Critical,
_ => Self::Error,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RetryHint {
#[default]
Unknown,
RetryImmediately,
RetryWithBackoff,
#[serde(rename = "retry_after")]
RetryAfter(u64),
DoNotRetry,
}
impl RetryHint {
pub fn as_str(&self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::RetryImmediately => "retry_immediately",
Self::RetryWithBackoff => "retry_with_backoff",
Self::RetryAfter(_) => "retry_after",
Self::DoNotRetry => "do_not_retry",
}
}
pub fn retry_after_ms(&self) -> Option<u64> {
match self {
Self::RetryAfter(ms) => Some(*ms),
_ => None,
}
}
pub fn should_retry(&self) -> bool {
matches!(
self,
Self::RetryImmediately | Self::RetryWithBackoff | Self::RetryAfter(_)
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredError {
pub code: String,
pub message: String,
#[serde(default)]
pub category: ErrorCategory,
#[serde(default)]
pub severity: ErrorSeverity,
#[serde(default)]
pub retry_hint: RetryHint,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_step_id: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub attributes: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cause: Option<Box<StructuredError>>,
}
impl StructuredError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
category: ErrorCategory::Unknown,
severity: ErrorSeverity::Error,
retry_hint: RetryHint::Unknown,
source_step_id: None,
attributes: HashMap::new(),
cause: None,
}
}
pub fn transient(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
category: ErrorCategory::Transient,
severity: ErrorSeverity::Error,
retry_hint: RetryHint::RetryWithBackoff,
source_step_id: None,
attributes: HashMap::new(),
cause: None,
}
}
pub fn permanent(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
category: ErrorCategory::Permanent,
severity: ErrorSeverity::Error,
retry_hint: RetryHint::DoNotRetry,
source_step_id: None,
attributes: HashMap::new(),
cause: None,
}
}
pub fn with_step(mut self, step_id: impl Into<String>) -> Self {
self.source_step_id = Some(step_id.into());
self
}
pub fn with_attr(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.insert(key.into(), value.into());
self
}
pub fn with_cause(mut self, cause: StructuredError) -> Self {
self.cause = Some(Box::new(cause));
self
}
pub fn with_severity(mut self, severity: ErrorSeverity) -> Self {
self.severity = severity;
self
}
pub fn with_retry_hint(mut self, hint: RetryHint) -> Self {
self.retry_hint = hint;
self
}
pub fn is_transient(&self) -> bool {
self.category == ErrorCategory::Transient
}
pub fn is_permanent(&self) -> bool {
self.category == ErrorCategory::Permanent
}
pub fn should_retry(&self) -> bool {
self.retry_hint.should_retry()
}
}
impl fmt::Display for StructuredError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for StructuredError {}
pub type Result<T> = std::result::Result<T, CoreError>;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum CoreError {
InstanceNotFound {
instance_id: String,
},
InstanceAlreadyExists {
instance_id: String,
},
InvalidInstanceState {
instance_id: String,
expected: String,
actual: String,
},
CheckpointNotFound {
instance_id: String,
checkpoint_id: Option<String>,
},
CheckpointSaveFailed {
instance_id: String,
reason: String,
},
SignalDeliveryFailed {
instance_id: String,
signal_type: String,
reason: String,
},
ValidationError {
field: String,
message: String,
},
DatabaseError {
operation: String,
details: String,
},
}
impl CoreError {
pub fn error_code(&self) -> &'static str {
match self {
Self::InstanceNotFound { .. } => "INSTANCE_NOT_FOUND",
Self::InstanceAlreadyExists { .. } => "INSTANCE_ALREADY_EXISTS",
Self::InvalidInstanceState { .. } => "INVALID_INSTANCE_STATE",
Self::CheckpointNotFound { .. } => "CHECKPOINT_NOT_FOUND",
Self::CheckpointSaveFailed { .. } => "CHECKPOINT_SAVE_FAILED",
Self::SignalDeliveryFailed { .. } => "SIGNAL_DELIVERY_FAILED",
Self::ValidationError { .. } => "VALIDATION_ERROR",
Self::DatabaseError { .. } => "DATABASE_ERROR",
}
}
}
impl fmt::Display for CoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InstanceNotFound { instance_id } => {
write!(f, "Instance '{}' not found", instance_id)
}
Self::InstanceAlreadyExists { instance_id } => {
write!(f, "Instance '{}' already exists", instance_id)
}
Self::InvalidInstanceState {
instance_id,
expected,
actual,
} => {
write!(
f,
"Instance '{}' is in invalid state: expected '{}', got '{}'",
instance_id, expected, actual
)
}
Self::CheckpointNotFound {
instance_id,
checkpoint_id,
} => {
if let Some(cp_id) = checkpoint_id {
write!(
f,
"Checkpoint '{}' not found for instance '{}'",
cp_id, instance_id
)
} else {
write!(f, "No checkpoints found for instance '{}'", instance_id)
}
}
Self::CheckpointSaveFailed {
instance_id,
reason,
} => {
write!(
f,
"Failed to save checkpoint for instance '{}': {}",
instance_id, reason
)
}
Self::SignalDeliveryFailed {
instance_id,
signal_type,
reason,
} => {
write!(
f,
"Failed to deliver {} signal to instance '{}': {}",
signal_type, instance_id, reason
)
}
Self::ValidationError { field, message } => {
write!(f, "Validation error for '{}': {}", field, message)
}
Self::DatabaseError { operation, details } => {
write!(f, "Database error during '{}': {}", operation, details)
}
}
}
}
impl std::error::Error for CoreError {}
impl From<sqlx::Error> for CoreError {
fn from(err: sqlx::Error) -> Self {
CoreError::DatabaseError {
operation: "query".to_string(),
details: err.to_string(),
}
}
}
impl From<serde_json::Error> for CoreError {
fn from(err: serde_json::Error) -> Self {
CoreError::DatabaseError {
operation: "json".to_string(),
details: err.to_string(),
}
}
}
impl From<CoreError> for StructuredError {
fn from(err: CoreError) -> Self {
let (category, severity) = match &err {
CoreError::InstanceNotFound { .. } => (ErrorCategory::Permanent, ErrorSeverity::Error),
CoreError::InstanceAlreadyExists { .. } => {
(ErrorCategory::Permanent, ErrorSeverity::Warning)
}
CoreError::InvalidInstanceState { .. } => {
(ErrorCategory::Permanent, ErrorSeverity::Error)
}
CoreError::CheckpointNotFound { .. } => {
(ErrorCategory::Permanent, ErrorSeverity::Error)
}
CoreError::CheckpointSaveFailed { .. } => {
(ErrorCategory::Transient, ErrorSeverity::Error)
}
CoreError::SignalDeliveryFailed { .. } => {
(ErrorCategory::Transient, ErrorSeverity::Warning)
}
CoreError::ValidationError { .. } => (ErrorCategory::Permanent, ErrorSeverity::Error),
CoreError::DatabaseError { .. } => (ErrorCategory::Transient, ErrorSeverity::Critical),
};
let retry_hint = if category == ErrorCategory::Transient {
RetryHint::RetryWithBackoff
} else {
RetryHint::DoNotRetry
};
Self {
code: err.error_code().to_string(),
message: err.to_string(),
category,
severity,
retry_hint,
source_step_id: None,
attributes: HashMap::new(),
cause: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_core_error_codes() {
let test_cases = vec![
(
CoreError::InstanceNotFound {
instance_id: "test-id".to_string(),
},
"INSTANCE_NOT_FOUND",
),
(
CoreError::InstanceAlreadyExists {
instance_id: "test-id".to_string(),
},
"INSTANCE_ALREADY_EXISTS",
),
(
CoreError::InvalidInstanceState {
instance_id: "test-id".to_string(),
expected: "running".to_string(),
actual: "pending".to_string(),
},
"INVALID_INSTANCE_STATE",
),
(
CoreError::CheckpointNotFound {
instance_id: "test-id".to_string(),
checkpoint_id: Some("cp-1".to_string()),
},
"CHECKPOINT_NOT_FOUND",
),
(
CoreError::CheckpointSaveFailed {
instance_id: "test-id".to_string(),
reason: "disk full".to_string(),
},
"CHECKPOINT_SAVE_FAILED",
),
(
CoreError::SignalDeliveryFailed {
instance_id: "test-id".to_string(),
signal_type: "cancel".to_string(),
reason: "timeout".to_string(),
},
"SIGNAL_DELIVERY_FAILED",
),
(
CoreError::ValidationError {
field: "instance_id".to_string(),
message: "invalid UUID".to_string(),
},
"VALIDATION_ERROR",
),
(
CoreError::DatabaseError {
operation: "insert".to_string(),
details: "connection refused".to_string(),
},
"DATABASE_ERROR",
),
];
for (error, expected_code) in test_cases {
assert_eq!(
error.error_code(),
expected_code,
"Error {:?} should have code {}",
error,
expected_code
);
assert!(!error.to_string().is_empty(), "Message should not be empty");
}
}
#[test]
fn test_core_error_display() {
let err = CoreError::InstanceNotFound {
instance_id: "abc-123".to_string(),
};
assert_eq!(err.to_string(), "Instance 'abc-123' not found");
let err = CoreError::InstanceAlreadyExists {
instance_id: "abc-123".to_string(),
};
assert_eq!(err.to_string(), "Instance 'abc-123' already exists");
let err = CoreError::InvalidInstanceState {
instance_id: "abc-123".to_string(),
expected: "running".to_string(),
actual: "pending".to_string(),
};
assert_eq!(
err.to_string(),
"Instance 'abc-123' is in invalid state: expected 'running', got 'pending'"
);
let err = CoreError::CheckpointNotFound {
instance_id: "abc-123".to_string(),
checkpoint_id: Some("cp-1".to_string()),
};
assert_eq!(
err.to_string(),
"Checkpoint 'cp-1' not found for instance 'abc-123'"
);
let err = CoreError::CheckpointNotFound {
instance_id: "abc-123".to_string(),
checkpoint_id: None,
};
assert_eq!(
err.to_string(),
"No checkpoints found for instance 'abc-123'"
);
let err = CoreError::ValidationError {
field: "instance_id".to_string(),
message: "must be a valid UUID".to_string(),
};
assert_eq!(
err.to_string(),
"Validation error for 'instance_id': must be a valid UUID"
);
let err = CoreError::DatabaseError {
operation: "insert".to_string(),
details: "connection refused".to_string(),
};
assert_eq!(
err.to_string(),
"Database error during 'insert': connection refused"
);
}
#[test]
fn test_error_code_method() {
assert_eq!(
CoreError::InstanceNotFound {
instance_id: "x".to_string()
}
.error_code(),
"INSTANCE_NOT_FOUND"
);
assert_eq!(
CoreError::ValidationError {
field: "x".to_string(),
message: "y".to_string()
}
.error_code(),
"VALIDATION_ERROR"
);
}
}