Skip to main content

pipedash_core/domain/
error.rs

1use std::time::Duration;
2
3use serde::{
4    Deserialize,
5    Serialize,
6};
7use thiserror::Error;
8
9#[derive(Error, Debug, Clone)]
10pub enum DomainError {
11    #[error("Provider not found: {0}")]
12    ProviderNotFound(String),
13
14    #[error("Pipeline not found: {0}")]
15    PipelineNotFound(String),
16
17    #[error("Not found: {0}")]
18    NotFound(String),
19
20    #[error("Invalid configuration: {0}")]
21    InvalidConfig(String),
22
23    #[error("Authentication failed: {0}")]
24    AuthenticationFailed(String),
25
26    #[error("Authentication failed: {0}")]
27    ProviderAuthenticationFailed(String),
28
29    #[error("Permission denied: {0}")]
30    PermissionDenied(String),
31
32    #[error("API error: {0}")]
33    ApiError(String),
34
35    #[error("Database error: {0}")]
36    DatabaseError(String),
37
38    #[error("Invalid provider type: {0}")]
39    InvalidProviderType(String),
40
41    #[error("Provider error: {0}")]
42    ProviderError(String),
43
44    #[error("Not supported: {0}")]
45    NotSupported(String),
46
47    #[error("Internal error: {0}")]
48    InternalError(String),
49
50    #[error("Rate limited: {message}")]
51    RateLimited {
52        message: String,
53        retry_after: Option<Duration>,
54    },
55
56    #[error("Service unavailable: {message}")]
57    ServiceUnavailable {
58        message: String,
59        retry_after: Option<Duration>,
60    },
61
62    #[error("Timeout: {0}")]
63    Timeout(String),
64
65    #[error("TLS error: {0}")]
66    Tls(String),
67
68    #[error("Network error: {0}")]
69    NetworkError(String),
70
71    #[error("Lock error: {0}")]
72    LockError(String),
73
74    #[error("Concurrent modification: {0}")]
75    ConcurrentModification(String),
76
77    #[error("Configuration file not found - setup wizard required")]
78    ConfigNotFound,
79
80    #[error("Configuration file is invalid: {path} - {errors}")]
81    InvalidConfigFile { path: String, errors: String },
82
83    #[error("Application not initialized - run setup first")]
84    NotInitialized,
85
86    #[error(
87        "Migration failed at step '{step}': {reason}\n\nRECOVERY INSTRUCTIONS:\n{instructions}"
88    )]
89    MigrationFailed {
90        step: String,
91        reason: String,
92        instructions: String,
93    },
94
95    #[error("Data consistency error: {0}")]
96    DataConsistency(String),
97}
98
99pub type DomainResult<T> = Result<T, DomainError>;
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum FailureKind {
104    Network,
105    Timeout,
106    Tls,
107    RateLimited,
108    Unavailable,
109    Auth,
110    Permission,
111    Config,
112    NotFound,
113    Other,
114}
115
116impl FailureKind {
117    pub fn is_transient(self) -> bool {
118        matches!(
119            self,
120            Self::Network | Self::Timeout | Self::Unavailable | Self::RateLimited
121        )
122    }
123}
124
125impl DomainError {
126    pub fn failure_kind(&self) -> FailureKind {
127        match self {
128            Self::NetworkError(_) => FailureKind::Network,
129            Self::Timeout(_) => FailureKind::Timeout,
130            Self::Tls(_) => FailureKind::Tls,
131            Self::RateLimited { .. } => FailureKind::RateLimited,
132            Self::ServiceUnavailable { .. } => FailureKind::Unavailable,
133            Self::AuthenticationFailed(_) | Self::ProviderAuthenticationFailed(_) => {
134                FailureKind::Auth
135            }
136            Self::PermissionDenied(_) => FailureKind::Permission,
137            Self::InvalidConfig(_)
138            | Self::InvalidProviderType(_)
139            | Self::InvalidConfigFile { .. }
140            | Self::ConfigNotFound => FailureKind::Config,
141            Self::ProviderNotFound(_) | Self::PipelineNotFound(_) | Self::NotFound(_) => {
142                FailureKind::NotFound
143            }
144            _ => FailureKind::Other,
145        }
146    }
147
148    pub fn retry_after(&self) -> Option<Duration> {
149        match self {
150            Self::RateLimited { retry_after, .. }
151            | Self::ServiceUnavailable { retry_after, .. } => *retry_after,
152            _ => None,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn failure_kind_classifies_transient_errors() {
163        let transient = [
164            DomainError::NetworkError("x".into()),
165            DomainError::Timeout("x".into()),
166            DomainError::ServiceUnavailable {
167                message: "x".into(),
168                retry_after: None,
169            },
170            DomainError::RateLimited {
171                message: "x".into(),
172                retry_after: Some(Duration::from_secs(5)),
173            },
174        ];
175        for err in transient {
176            assert!(err.failure_kind().is_transient(), "{err:?}");
177        }
178
179        let permanent = [
180            (DomainError::Tls("x".into()), FailureKind::Tls),
181            (
182                DomainError::ProviderAuthenticationFailed("x".into()),
183                FailureKind::Auth,
184            ),
185            (
186                DomainError::PermissionDenied("x".into()),
187                FailureKind::Permission,
188            ),
189            (DomainError::InvalidConfig("x".into()), FailureKind::Config),
190            (
191                DomainError::PipelineNotFound("x".into()),
192                FailureKind::NotFound,
193            ),
194            (DomainError::ApiError("x".into()), FailureKind::Other),
195        ];
196        for (err, kind) in permanent {
197            assert_eq!(err.failure_kind(), kind);
198            assert!(!kind.is_transient());
199        }
200    }
201}