1use serde::{Deserialize, Serialize};
8
9pub type Result<T, E = AppError> = std::result::Result<T, E>;
10
11#[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 pub fn is_retryable(self) -> bool {
33 matches!(
34 self,
35 Self::Network | Self::Offline | Self::RateLimited | Self::ExternalService
36 )
37 }
38
39 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 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 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#[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}