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    pub fn code(&self) -> &'static str {
157        match self {
158            Self::ProviderNotFound(_) | Self::PipelineNotFound(_) | Self::NotFound(_) => {
159                "NOT_FOUND"
160            }
161            Self::InvalidConfig(_) | Self::InvalidProviderType(_) => "BAD_REQUEST",
162            Self::AuthenticationFailed(_) => "UNAUTHORIZED",
163            Self::ProviderAuthenticationFailed(_) => "PROVIDER_AUTH_FAILED",
164            Self::PermissionDenied(_) => "FORBIDDEN",
165            Self::NotSupported(_) => "NOT_SUPPORTED",
166            _ => "INTERNAL_ERROR",
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn failure_kind_classifies_transient_errors() {
177        let transient = [
178            DomainError::NetworkError("x".into()),
179            DomainError::Timeout("x".into()),
180            DomainError::ServiceUnavailable {
181                message: "x".into(),
182                retry_after: None,
183            },
184            DomainError::RateLimited {
185                message: "x".into(),
186                retry_after: Some(Duration::from_secs(5)),
187            },
188        ];
189        for err in transient {
190            assert!(err.failure_kind().is_transient(), "{err:?}");
191        }
192
193        let permanent = [
194            (DomainError::Tls("x".into()), FailureKind::Tls),
195            (
196                DomainError::ProviderAuthenticationFailed("x".into()),
197                FailureKind::Auth,
198            ),
199            (
200                DomainError::PermissionDenied("x".into()),
201                FailureKind::Permission,
202            ),
203            (DomainError::InvalidConfig("x".into()), FailureKind::Config),
204            (
205                DomainError::PipelineNotFound("x".into()),
206                FailureKind::NotFound,
207            ),
208            (DomainError::ApiError("x".into()), FailureKind::Other),
209        ];
210        for (err, kind) in permanent {
211            assert_eq!(err.failure_kind(), kind);
212            assert!(!kind.is_transient());
213        }
214    }
215
216    #[test]
217    fn codes_are_stable_per_variant() {
218        let cases = [
219            (DomainError::ProviderNotFound("x".into()), "NOT_FOUND"),
220            (DomainError::PipelineNotFound("x".into()), "NOT_FOUND"),
221            (DomainError::NotFound("x".into()), "NOT_FOUND"),
222            (DomainError::InvalidConfig("x".into()), "BAD_REQUEST"),
223            (DomainError::InvalidProviderType("x".into()), "BAD_REQUEST"),
224            (
225                DomainError::AuthenticationFailed("x".into()),
226                "UNAUTHORIZED",
227            ),
228            (
229                DomainError::ProviderAuthenticationFailed("x".into()),
230                "PROVIDER_AUTH_FAILED",
231            ),
232            (DomainError::PermissionDenied("x".into()), "FORBIDDEN"),
233            (DomainError::NotSupported("x".into()), "NOT_SUPPORTED"),
234            (DomainError::ApiError("x".into()), "INTERNAL_ERROR"),
235            (DomainError::ConfigNotFound, "INTERNAL_ERROR"),
236            (
237                DomainError::RateLimited {
238                    message: "x".into(),
239                    retry_after: None,
240                },
241                "INTERNAL_ERROR",
242            ),
243        ];
244        for (err, code) in cases {
245            assert_eq!(err.code(), code, "{err:?}");
246        }
247    }
248}