Skip to main content

origin_domain/
error.rs

1//! The normalised error model.
2//!
3//! Adapters translate their native errors (`reqwest`, `rusqlite`, `tauri`, ...) into
4//! [`AppError`] at the boundary. Nothing else ever reaches the frontend, so the UI can
5//! offer one consistent error experience.
6
7use serde::{Deserialize, Serialize};
8
9pub type Result<T, E = AppError> = std::result::Result<T, E>;
10
11/// Stable, serialisable classification of a failure.
12///
13/// The frontend switches on this — never on an error message.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
16#[serde(rename_all = "snake_case")]
17pub enum ErrorKind {
18    Authentication,
19    Permission,
20    Network,
21    Offline,
22    RateLimited,
23    Storage,
24    ExternalService,
25    Validation,
26    Configuration,
27    Internal,
28}
29
30impl ErrorKind {
31    /// Whether retrying the same operation later can plausibly succeed.
32    pub fn is_retryable(self) -> bool {
33        matches!(
34            self,
35            Self::Network | Self::Offline | Self::RateLimited | Self::ExternalService
36        )
37    }
38
39    /// Whether the user has to act (re-authenticate, grant access, fix config).
40    pub fn needs_user_action(self) -> bool {
41        matches!(
42            self,
43            Self::Authentication | Self::Permission | Self::Configuration | Self::Validation
44        )
45    }
46}
47
48#[derive(Debug, thiserror::Error)]
49pub enum AppError {
50    #[error("authentication failed: {0}")]
51    Authentication(String),
52
53    #[error("permission denied: {0}")]
54    Permission(String),
55
56    #[error("network error: {0}")]
57    Network(String),
58
59    #[error("offline: {0}")]
60    Offline(String),
61
62    #[error("rate limited: {message}")]
63    RateLimited {
64        message: String,
65        /// Seconds to wait before the next attempt, if the service told us.
66        retry_after_seconds: Option<u64>,
67    },
68
69    #[error("storage error: {0}")]
70    Storage(String),
71
72    #[error("external service error: {0}")]
73    ExternalService(String),
74
75    #[error("validation error: {0}")]
76    Validation(String),
77
78    #[error("configuration error: {0}")]
79    Configuration(String),
80
81    #[error("internal error: {0}")]
82    Internal(String),
83}
84
85impl AppError {
86    pub fn kind(&self) -> ErrorKind {
87        match self {
88            Self::Authentication(_) => ErrorKind::Authentication,
89            Self::Permission(_) => ErrorKind::Permission,
90            Self::Network(_) => ErrorKind::Network,
91            Self::Offline(_) => ErrorKind::Offline,
92            Self::RateLimited { .. } => ErrorKind::RateLimited,
93            Self::Storage(_) => ErrorKind::Storage,
94            Self::ExternalService(_) => ErrorKind::ExternalService,
95            Self::Validation(_) => ErrorKind::Validation,
96            Self::Configuration(_) => ErrorKind::Configuration,
97            Self::Internal(_) => ErrorKind::Internal,
98        }
99    }
100
101    pub fn is_retryable(&self) -> bool {
102        self.kind().is_retryable()
103    }
104
105    /// The IPC representation. This is what the frontend receives — never a raw
106    /// `rusqlite::Error` or `reqwest::Error`.
107    pub fn to_contract(&self) -> ErrorContract {
108        ErrorContract {
109            kind: self.kind(),
110            message: self.to_string(),
111            retryable: self.is_retryable(),
112            needs_user_action: self.kind().needs_user_action(),
113            retry_after_seconds: match self {
114                Self::RateLimited {
115                    retry_after_seconds,
116                    ..
117                } => *retry_after_seconds,
118                _ => None,
119            },
120        }
121    }
122
123    pub fn storage(message: impl Into<String>) -> Self {
124        Self::Storage(message.into())
125    }
126
127    pub fn internal(message: impl Into<String>) -> Self {
128        Self::Internal(message.into())
129    }
130
131    pub fn validation(message: impl Into<String>) -> Self {
132        Self::Validation(message.into())
133    }
134
135    pub fn configuration(message: impl Into<String>) -> Self {
136        Self::Configuration(message.into())
137    }
138}
139
140/// Serialisable error payload crossing the IPC boundary.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
143pub struct ErrorContract {
144    pub kind: ErrorKind,
145    pub message: String,
146    pub retryable: bool,
147    pub needs_user_action: bool,
148    pub retry_after_seconds: Option<u64>,
149}
150
151impl From<&AppError> for ErrorContract {
152    fn from(error: &AppError) -> Self {
153        error.to_contract()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn rate_limit_is_retryable_and_carries_retry_after() {
163        let error = AppError::RateLimited {
164            message: "secondary rate limit".into(),
165            retry_after_seconds: Some(60),
166        };
167        let contract = error.to_contract();
168
169        assert_eq!(contract.kind, ErrorKind::RateLimited);
170        assert!(contract.retryable);
171        assert_eq!(contract.retry_after_seconds, Some(60));
172    }
173
174    #[test]
175    fn authentication_needs_user_action_and_is_not_retryable() {
176        let contract = AppError::Authentication("token expired".into()).to_contract();
177
178        assert!(contract.needs_user_action);
179        assert!(!contract.retryable);
180    }
181}