1#![allow(dead_code)] use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fmt;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum ErrorCategory {
30 #[default]
32 Unknown,
33 Transient,
35 Permanent,
37}
38
39impl ErrorCategory {
40 pub fn as_str(&self) -> &'static str {
42 match self {
43 Self::Unknown => "unknown",
44 Self::Transient => "transient",
45 Self::Permanent => "permanent",
46 }
47 }
48
49 pub fn parse(s: &str) -> Self {
51 match s {
52 "transient" => Self::Transient,
53 "permanent" => Self::Permanent,
54 "business" => Self::Permanent,
56 _ => Self::Unknown,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
63#[serde(rename_all = "snake_case")]
64pub enum ErrorSeverity {
65 Info,
67 Warning,
69 #[default]
71 Error,
72 Critical,
74}
75
76impl ErrorSeverity {
77 pub fn as_str(&self) -> &'static str {
79 match self {
80 Self::Info => "info",
81 Self::Warning => "warning",
82 Self::Error => "error",
83 Self::Critical => "critical",
84 }
85 }
86
87 pub fn parse(s: &str) -> Self {
89 match s {
90 "info" => Self::Info,
91 "warning" => Self::Warning,
92 "critical" => Self::Critical,
93 _ => Self::Error,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
100#[serde(rename_all = "snake_case")]
101pub enum RetryHint {
102 #[default]
104 Unknown,
105 RetryImmediately,
107 RetryWithBackoff,
109 #[serde(rename = "retry_after")]
111 RetryAfter(u64),
112 DoNotRetry,
114}
115
116impl RetryHint {
117 pub fn as_str(&self) -> &'static str {
119 match self {
120 Self::Unknown => "unknown",
121 Self::RetryImmediately => "retry_immediately",
122 Self::RetryWithBackoff => "retry_with_backoff",
123 Self::RetryAfter(_) => "retry_after",
124 Self::DoNotRetry => "do_not_retry",
125 }
126 }
127
128 pub fn retry_after_ms(&self) -> Option<u64> {
130 match self {
131 Self::RetryAfter(ms) => Some(*ms),
132 _ => None,
133 }
134 }
135
136 pub fn should_retry(&self) -> bool {
138 matches!(
139 self,
140 Self::RetryImmediately | Self::RetryWithBackoff | Self::RetryAfter(_)
141 )
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct StructuredError {
155 pub code: String,
157 pub message: String,
159 #[serde(default)]
161 pub category: ErrorCategory,
162 #[serde(default)]
164 pub severity: ErrorSeverity,
165 #[serde(default)]
167 pub retry_hint: RetryHint,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub source_step_id: Option<String>,
171 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
173 pub attributes: HashMap<String, String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub cause: Option<Box<StructuredError>>,
177}
178
179impl StructuredError {
180 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
182 Self {
183 code: code.into(),
184 message: message.into(),
185 category: ErrorCategory::Unknown,
186 severity: ErrorSeverity::Error,
187 retry_hint: RetryHint::Unknown,
188 source_step_id: None,
189 attributes: HashMap::new(),
190 cause: None,
191 }
192 }
193
194 pub fn transient(code: impl Into<String>, message: impl Into<String>) -> Self {
196 Self {
197 code: code.into(),
198 message: message.into(),
199 category: ErrorCategory::Transient,
200 severity: ErrorSeverity::Error,
201 retry_hint: RetryHint::RetryWithBackoff,
202 source_step_id: None,
203 attributes: HashMap::new(),
204 cause: None,
205 }
206 }
207
208 pub fn permanent(code: impl Into<String>, message: impl Into<String>) -> Self {
215 Self {
216 code: code.into(),
217 message: message.into(),
218 category: ErrorCategory::Permanent,
219 severity: ErrorSeverity::Error,
220 retry_hint: RetryHint::DoNotRetry,
221 source_step_id: None,
222 attributes: HashMap::new(),
223 cause: None,
224 }
225 }
226
227 pub fn with_step(mut self, step_id: impl Into<String>) -> Self {
229 self.source_step_id = Some(step_id.into());
230 self
231 }
232
233 pub fn with_attr(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
235 self.attributes.insert(key.into(), value.into());
236 self
237 }
238
239 pub fn with_cause(mut self, cause: StructuredError) -> Self {
241 self.cause = Some(Box::new(cause));
242 self
243 }
244
245 pub fn with_severity(mut self, severity: ErrorSeverity) -> Self {
247 self.severity = severity;
248 self
249 }
250
251 pub fn with_retry_hint(mut self, hint: RetryHint) -> Self {
253 self.retry_hint = hint;
254 self
255 }
256
257 pub fn is_transient(&self) -> bool {
259 self.category == ErrorCategory::Transient
260 }
261
262 pub fn is_permanent(&self) -> bool {
264 self.category == ErrorCategory::Permanent
265 }
266
267 pub fn should_retry(&self) -> bool {
269 self.retry_hint.should_retry()
270 }
271}
272
273impl fmt::Display for StructuredError {
274 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275 write!(f, "[{}] {}", self.code, self.message)
276 }
277}
278
279impl std::error::Error for StructuredError {}
280
281pub type Result<T> = std::result::Result<T, CoreError>;
287
288#[derive(Debug, Clone)]
290#[non_exhaustive]
291pub enum CoreError {
292 InstanceNotFound {
294 instance_id: String,
296 },
297
298 InstanceAlreadyExists {
300 instance_id: String,
302 },
303
304 InvalidInstanceState {
306 instance_id: String,
308 expected: String,
310 actual: String,
312 },
313
314 CheckpointNotFound {
316 instance_id: String,
318 checkpoint_id: Option<String>,
320 },
321
322 CheckpointSaveFailed {
324 instance_id: String,
326 reason: String,
328 },
329
330 SignalDeliveryFailed {
332 instance_id: String,
334 signal_type: String,
336 reason: String,
338 },
339
340 ValidationError {
342 field: String,
344 message: String,
346 },
347
348 DatabaseError {
350 operation: String,
352 details: String,
354 },
355}
356
357impl CoreError {
358 pub fn error_code(&self) -> &'static str {
360 match self {
361 Self::InstanceNotFound { .. } => "INSTANCE_NOT_FOUND",
362 Self::InstanceAlreadyExists { .. } => "INSTANCE_ALREADY_EXISTS",
363 Self::InvalidInstanceState { .. } => "INVALID_INSTANCE_STATE",
364 Self::CheckpointNotFound { .. } => "CHECKPOINT_NOT_FOUND",
365 Self::CheckpointSaveFailed { .. } => "CHECKPOINT_SAVE_FAILED",
366 Self::SignalDeliveryFailed { .. } => "SIGNAL_DELIVERY_FAILED",
367 Self::ValidationError { .. } => "VALIDATION_ERROR",
368 Self::DatabaseError { .. } => "DATABASE_ERROR",
369 }
370 }
371}
372
373impl fmt::Display for CoreError {
374 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375 match self {
376 Self::InstanceNotFound { instance_id } => {
377 write!(f, "Instance '{}' not found", instance_id)
378 }
379 Self::InstanceAlreadyExists { instance_id } => {
380 write!(f, "Instance '{}' already exists", instance_id)
381 }
382 Self::InvalidInstanceState {
383 instance_id,
384 expected,
385 actual,
386 } => {
387 write!(
388 f,
389 "Instance '{}' is in invalid state: expected '{}', got '{}'",
390 instance_id, expected, actual
391 )
392 }
393 Self::CheckpointNotFound {
394 instance_id,
395 checkpoint_id,
396 } => {
397 if let Some(cp_id) = checkpoint_id {
398 write!(
399 f,
400 "Checkpoint '{}' not found for instance '{}'",
401 cp_id, instance_id
402 )
403 } else {
404 write!(f, "No checkpoints found for instance '{}'", instance_id)
405 }
406 }
407 Self::CheckpointSaveFailed {
408 instance_id,
409 reason,
410 } => {
411 write!(
412 f,
413 "Failed to save checkpoint for instance '{}': {}",
414 instance_id, reason
415 )
416 }
417 Self::SignalDeliveryFailed {
418 instance_id,
419 signal_type,
420 reason,
421 } => {
422 write!(
423 f,
424 "Failed to deliver {} signal to instance '{}': {}",
425 signal_type, instance_id, reason
426 )
427 }
428 Self::ValidationError { field, message } => {
429 write!(f, "Validation error for '{}': {}", field, message)
430 }
431 Self::DatabaseError { operation, details } => {
432 write!(f, "Database error during '{}': {}", operation, details)
433 }
434 }
435 }
436}
437
438impl std::error::Error for CoreError {}
439
440impl From<sqlx::Error> for CoreError {
441 fn from(err: sqlx::Error) -> Self {
442 CoreError::DatabaseError {
443 operation: "query".to_string(),
444 details: err.to_string(),
445 }
446 }
447}
448
449impl From<serde_json::Error> for CoreError {
450 fn from(err: serde_json::Error) -> Self {
451 CoreError::DatabaseError {
452 operation: "json".to_string(),
453 details: err.to_string(),
454 }
455 }
456}
457
458impl From<CoreError> for StructuredError {
459 fn from(err: CoreError) -> Self {
460 let (category, severity) = match &err {
461 CoreError::InstanceNotFound { .. } => (ErrorCategory::Permanent, ErrorSeverity::Error),
462 CoreError::InstanceAlreadyExists { .. } => {
463 (ErrorCategory::Permanent, ErrorSeverity::Warning)
464 }
465 CoreError::InvalidInstanceState { .. } => {
466 (ErrorCategory::Permanent, ErrorSeverity::Error)
467 }
468 CoreError::CheckpointNotFound { .. } => {
469 (ErrorCategory::Permanent, ErrorSeverity::Error)
470 }
471 CoreError::CheckpointSaveFailed { .. } => {
472 (ErrorCategory::Transient, ErrorSeverity::Error)
473 }
474 CoreError::SignalDeliveryFailed { .. } => {
475 (ErrorCategory::Transient, ErrorSeverity::Warning)
476 }
477 CoreError::ValidationError { .. } => (ErrorCategory::Permanent, ErrorSeverity::Error),
478 CoreError::DatabaseError { .. } => (ErrorCategory::Transient, ErrorSeverity::Critical),
479 };
480
481 let retry_hint = if category == ErrorCategory::Transient {
482 RetryHint::RetryWithBackoff
483 } else {
484 RetryHint::DoNotRetry
485 };
486
487 Self {
488 code: err.error_code().to_string(),
489 message: err.to_string(),
490 category,
491 severity,
492 retry_hint,
493 source_step_id: None,
494 attributes: HashMap::new(),
495 cause: None,
496 }
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn test_core_error_codes() {
506 let test_cases = vec![
507 (
508 CoreError::InstanceNotFound {
509 instance_id: "test-id".to_string(),
510 },
511 "INSTANCE_NOT_FOUND",
512 ),
513 (
514 CoreError::InstanceAlreadyExists {
515 instance_id: "test-id".to_string(),
516 },
517 "INSTANCE_ALREADY_EXISTS",
518 ),
519 (
520 CoreError::InvalidInstanceState {
521 instance_id: "test-id".to_string(),
522 expected: "running".to_string(),
523 actual: "pending".to_string(),
524 },
525 "INVALID_INSTANCE_STATE",
526 ),
527 (
528 CoreError::CheckpointNotFound {
529 instance_id: "test-id".to_string(),
530 checkpoint_id: Some("cp-1".to_string()),
531 },
532 "CHECKPOINT_NOT_FOUND",
533 ),
534 (
535 CoreError::CheckpointSaveFailed {
536 instance_id: "test-id".to_string(),
537 reason: "disk full".to_string(),
538 },
539 "CHECKPOINT_SAVE_FAILED",
540 ),
541 (
542 CoreError::SignalDeliveryFailed {
543 instance_id: "test-id".to_string(),
544 signal_type: "cancel".to_string(),
545 reason: "timeout".to_string(),
546 },
547 "SIGNAL_DELIVERY_FAILED",
548 ),
549 (
550 CoreError::ValidationError {
551 field: "instance_id".to_string(),
552 message: "invalid UUID".to_string(),
553 },
554 "VALIDATION_ERROR",
555 ),
556 (
557 CoreError::DatabaseError {
558 operation: "insert".to_string(),
559 details: "connection refused".to_string(),
560 },
561 "DATABASE_ERROR",
562 ),
563 ];
564
565 for (error, expected_code) in test_cases {
566 assert_eq!(
567 error.error_code(),
568 expected_code,
569 "Error {:?} should have code {}",
570 error,
571 expected_code
572 );
573 assert!(!error.to_string().is_empty(), "Message should not be empty");
574 }
575 }
576
577 #[test]
578 fn test_core_error_display() {
579 let err = CoreError::InstanceNotFound {
581 instance_id: "abc-123".to_string(),
582 };
583 assert_eq!(err.to_string(), "Instance 'abc-123' not found");
584
585 let err = CoreError::InstanceAlreadyExists {
587 instance_id: "abc-123".to_string(),
588 };
589 assert_eq!(err.to_string(), "Instance 'abc-123' already exists");
590
591 let err = CoreError::InvalidInstanceState {
593 instance_id: "abc-123".to_string(),
594 expected: "running".to_string(),
595 actual: "pending".to_string(),
596 };
597 assert_eq!(
598 err.to_string(),
599 "Instance 'abc-123' is in invalid state: expected 'running', got 'pending'"
600 );
601
602 let err = CoreError::CheckpointNotFound {
604 instance_id: "abc-123".to_string(),
605 checkpoint_id: Some("cp-1".to_string()),
606 };
607 assert_eq!(
608 err.to_string(),
609 "Checkpoint 'cp-1' not found for instance 'abc-123'"
610 );
611
612 let err = CoreError::CheckpointNotFound {
614 instance_id: "abc-123".to_string(),
615 checkpoint_id: None,
616 };
617 assert_eq!(
618 err.to_string(),
619 "No checkpoints found for instance 'abc-123'"
620 );
621
622 let err = CoreError::ValidationError {
624 field: "instance_id".to_string(),
625 message: "must be a valid UUID".to_string(),
626 };
627 assert_eq!(
628 err.to_string(),
629 "Validation error for 'instance_id': must be a valid UUID"
630 );
631
632 let err = CoreError::DatabaseError {
634 operation: "insert".to_string(),
635 details: "connection refused".to_string(),
636 };
637 assert_eq!(
638 err.to_string(),
639 "Database error during 'insert': connection refused"
640 );
641 }
642
643 #[test]
644 fn test_error_code_method() {
645 assert_eq!(
646 CoreError::InstanceNotFound {
647 instance_id: "x".to_string()
648 }
649 .error_code(),
650 "INSTANCE_NOT_FOUND"
651 );
652 assert_eq!(
653 CoreError::ValidationError {
654 field: "x".to_string(),
655 message: "y".to_string()
656 }
657 .error_code(),
658 "VALIDATION_ERROR"
659 );
660 }
661}