Skip to main content

runtara_core/
error.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Error types for runtara-core.
4//!
5//! Provides a unified error type that maps to RPC error responses,
6//! including structured error types with category, severity, and retry hints.
7
8#![allow(dead_code)] // Variants and methods used in tests and for future expansion
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fmt;
13
14// ============================================================================
15// Structured Error Types
16// ============================================================================
17
18/// Error category for retry/routing decisions.
19///
20/// Two categories:
21/// - **Transient**: Auto-retry likely to succeed (network, timeout, rate limit)
22/// - **Permanent**: Don't auto-retry (validation, not found, auth, business rules)
23///
24/// To distinguish technical vs business errors within Permanent, use:
25/// - `code`: e.g., `VALIDATION_*` for technical, `BUSINESS_*` or domain-specific codes
26/// - `severity`: `Error` for technical failures, `Warning` for expected business outcomes
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum ErrorCategory {
30    /// Unknown category - use default retry policy.
31    #[default]
32    Unknown,
33    /// Transient error - retry is likely to succeed (network, timeout, rate limit).
34    Transient,
35    /// Permanent error - don't retry (validation, not found, authorization, business rules).
36    Permanent,
37}
38
39impl ErrorCategory {
40    /// Returns the string representation of the category.
41    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    /// Parse a category from a string.
50    pub fn parse(s: &str) -> Self {
51        match s {
52            "transient" => Self::Transient,
53            "permanent" => Self::Permanent,
54            // Legacy: map "business" to "permanent" for backwards compatibility
55            "business" => Self::Permanent,
56            _ => Self::Unknown,
57        }
58    }
59}
60
61/// Error severity for logging/alerting.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
63#[serde(rename_all = "snake_case")]
64pub enum ErrorSeverity {
65    /// Informational - expected errors that don't indicate a problem.
66    Info,
67    /// Warning - degraded but functional operation.
68    Warning,
69    /// Error - operation failed (default severity).
70    #[default]
71    Error,
72    /// Critical - system-level failure requiring immediate attention.
73    Critical,
74}
75
76impl ErrorSeverity {
77    /// Returns the string representation of the severity.
78    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    /// Parse a severity from a string.
88    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/// Hint for retry behavior.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
100#[serde(rename_all = "snake_case")]
101pub enum RetryHint {
102    /// Unknown - use default retry policy.
103    #[default]
104    Unknown,
105    /// Retry immediately - transient glitch likely resolved.
106    RetryImmediately,
107    /// Retry with exponential backoff - standard retry strategy.
108    RetryWithBackoff,
109    /// Retry after specific duration in milliseconds (e.g., rate limit).
110    #[serde(rename = "retry_after")]
111    RetryAfter(u64),
112    /// Do not retry - permanent error.
113    DoNotRetry,
114}
115
116impl RetryHint {
117    /// Returns the string representation of the hint.
118    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    /// Returns the retry delay in milliseconds if this is a RetryAfter hint.
129    pub fn retry_after_ms(&self) -> Option<u64> {
130        match self {
131            Self::RetryAfter(ms) => Some(*ms),
132            _ => None,
133        }
134    }
135
136    /// Returns true if this error should be retried.
137    pub fn should_retry(&self) -> bool {
138        matches!(
139            self,
140            Self::RetryImmediately | Self::RetryWithBackoff | Self::RetryAfter(_)
141        )
142    }
143}
144
145/// Structured error with metadata for intelligent error handling.
146///
147/// This extends the basic error information with:
148/// - Category: transient vs permanent (business errors are a subset of permanent)
149/// - Severity: info/warning/error/critical for alerting
150/// - Retry hint: whether and how to retry
151/// - Attributes: additional context key-value pairs
152/// - Cause: nested error for error chains
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct StructuredError {
155    /// Machine-readable error code (e.g., "RATE_LIMITED", "VALIDATION_ERROR")
156    pub code: String,
157    /// Human-readable error message
158    pub message: String,
159    /// Error category for routing/retry decisions
160    #[serde(default)]
161    pub category: ErrorCategory,
162    /// Error severity for logging/alerting
163    #[serde(default)]
164    pub severity: ErrorSeverity,
165    /// Hint for retry behavior
166    #[serde(default)]
167    pub retry_hint: RetryHint,
168    /// Which step produced this error (if applicable)
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub source_step_id: Option<String>,
171    /// Additional context as key-value pairs
172    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
173    pub attributes: HashMap<String, String>,
174    /// Nested cause error (for error chains)
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub cause: Option<Box<StructuredError>>,
177}
178
179impl StructuredError {
180    /// Create a new structured error with minimal info.
181    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    /// Create a transient error (retry recommended).
195    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    /// Create a permanent error (don't retry).
209    ///
210    /// Use for: validation errors, not found, authorization failures, business rule violations.
211    ///
212    /// For business errors (expected outcomes like "credit limit exceeded"), use
213    /// `.with_severity(ErrorSeverity::Warning)` to distinguish from technical failures.
214    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    /// Set the source step ID.
228    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    /// Add an attribute.
234    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    /// Set a cause error.
240    pub fn with_cause(mut self, cause: StructuredError) -> Self {
241        self.cause = Some(Box::new(cause));
242        self
243    }
244
245    /// Set the severity.
246    pub fn with_severity(mut self, severity: ErrorSeverity) -> Self {
247        self.severity = severity;
248        self
249    }
250
251    /// Set the retry hint.
252    pub fn with_retry_hint(mut self, hint: RetryHint) -> Self {
253        self.retry_hint = hint;
254        self
255    }
256
257    /// Returns true if this is a transient error.
258    pub fn is_transient(&self) -> bool {
259        self.category == ErrorCategory::Transient
260    }
261
262    /// Returns true if this is a permanent error.
263    pub fn is_permanent(&self) -> bool {
264        self.category == ErrorCategory::Permanent
265    }
266
267    /// Returns true if this error should be retried.
268    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
281// ============================================================================
282// CoreError (existing)
283// ============================================================================
284
285/// Result type using CoreError
286pub type Result<T> = std::result::Result<T, CoreError>;
287
288/// Core errors that can occur during request processing.
289#[derive(Debug, Clone)]
290#[non_exhaustive]
291pub enum CoreError {
292    /// Instance was not found in the database.
293    InstanceNotFound {
294        /// The instance ID that was not found.
295        instance_id: String,
296    },
297
298    /// Instance already exists (duplicate registration).
299    InstanceAlreadyExists {
300        /// The instance ID that already exists.
301        instance_id: String,
302    },
303
304    /// Instance is in an invalid state for the requested operation.
305    InvalidInstanceState {
306        /// The instance ID.
307        instance_id: String,
308        /// The expected status.
309        expected: String,
310        /// The actual status.
311        actual: String,
312    },
313
314    /// Checkpoint was not found.
315    CheckpointNotFound {
316        /// The instance ID.
317        instance_id: String,
318        /// The checkpoint ID that was not found.
319        checkpoint_id: Option<String>,
320    },
321
322    /// Checkpoint save failed.
323    CheckpointSaveFailed {
324        /// The instance ID.
325        instance_id: String,
326        /// The reason for failure.
327        reason: String,
328    },
329
330    /// Signal delivery failed.
331    SignalDeliveryFailed {
332        /// The instance ID.
333        instance_id: String,
334        /// The signal type that failed.
335        signal_type: String,
336        /// The reason for failure.
337        reason: String,
338    },
339
340    /// Input validation failed.
341    ValidationError {
342        /// The field that failed validation.
343        field: String,
344        /// The validation error message.
345        message: String,
346    },
347
348    /// Database operation failed.
349    DatabaseError {
350        /// The operation that failed.
351        operation: String,
352        /// Error details.
353        details: String,
354    },
355}
356
357impl CoreError {
358    /// Get the error code string for this error type.
359    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        // Test InstanceNotFound
580        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        // Test InstanceAlreadyExists
586        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        // Test InvalidInstanceState
592        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        // Test CheckpointNotFound with checkpoint_id
603        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        // Test CheckpointNotFound without checkpoint_id
613        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        // Test ValidationError
623        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        // Test DatabaseError
633        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}