1use std::time::Duration;
2
3use thiserror::Error;
4
5pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
9
10#[derive(Debug, Error)]
30pub enum PluginError {
31 #[error("transient error: {message}")]
33 Transient {
34 message: String,
35 #[source]
36 source: Option<BoxError>,
37 },
38
39 #[error("rate limited")]
41 RateLimited {
42 retry_after: Option<Duration>,
43 #[source]
44 source: Option<BoxError>,
45 },
46
47 #[error("timeout")]
49 Timeout {
50 #[source]
51 source: Option<BoxError>,
52 },
53
54 #[error("invalid input: {message}")]
56 InvalidInput {
57 message: String,
58 #[source]
59 source: Option<BoxError>,
60 },
61
62 #[error("unauthorized: {message}")]
64 Unauthorized {
65 message: String,
66 #[source]
67 source: Option<BoxError>,
68 },
69
70 #[error("not found: {message}")]
72 NotFound {
73 message: String,
74 #[source]
75 source: Option<BoxError>,
76 },
77
78 #[error("internal error: {message}")]
81 Internal {
82 message: String,
83 #[source]
84 source: Option<BoxError>,
85 },
86}
87
88impl PluginError {
89 pub fn transient(message: impl Into<String>) -> Self {
92 Self::Transient {
93 message: message.into(),
94 source: None,
95 }
96 }
97
98 pub fn transient_with<E>(message: impl Into<String>, source: E) -> Self
99 where
100 E: std::error::Error + Send + Sync + 'static,
101 {
102 Self::Transient {
103 message: message.into(),
104 source: Some(Box::new(source)),
105 }
106 }
107
108 pub fn rate_limited(retry_after: Option<Duration>) -> Self {
109 Self::RateLimited {
110 retry_after,
111 source: None,
112 }
113 }
114
115 pub fn rate_limited_with<E>(retry_after: Option<Duration>, source: E) -> Self
116 where
117 E: std::error::Error + Send + Sync + 'static,
118 {
119 Self::RateLimited {
120 retry_after,
121 source: Some(Box::new(source)),
122 }
123 }
124
125 pub fn timeout() -> Self {
126 Self::Timeout { source: None }
127 }
128
129 pub fn timeout_with<E>(source: E) -> Self
130 where
131 E: std::error::Error + Send + Sync + 'static,
132 {
133 Self::Timeout {
134 source: Some(Box::new(source)),
135 }
136 }
137
138 pub fn invalid_input(message: impl Into<String>) -> Self {
139 Self::InvalidInput {
140 message: message.into(),
141 source: None,
142 }
143 }
144
145 pub fn invalid_input_with<E>(message: impl Into<String>, source: E) -> Self
146 where
147 E: std::error::Error + Send + Sync + 'static,
148 {
149 Self::InvalidInput {
150 message: message.into(),
151 source: Some(Box::new(source)),
152 }
153 }
154
155 pub fn unauthorized(message: impl Into<String>) -> Self {
156 Self::Unauthorized {
157 message: message.into(),
158 source: None,
159 }
160 }
161
162 pub fn unauthorized_with<E>(message: impl Into<String>, source: E) -> Self
163 where
164 E: std::error::Error + Send + Sync + 'static,
165 {
166 Self::Unauthorized {
167 message: message.into(),
168 source: Some(Box::new(source)),
169 }
170 }
171
172 pub fn not_found(message: impl Into<String>) -> Self {
173 Self::NotFound {
174 message: message.into(),
175 source: None,
176 }
177 }
178
179 pub fn not_found_with<E>(message: impl Into<String>, source: E) -> Self
180 where
181 E: std::error::Error + Send + Sync + 'static,
182 {
183 Self::NotFound {
184 message: message.into(),
185 source: Some(Box::new(source)),
186 }
187 }
188
189 pub fn internal(message: impl Into<String>) -> Self {
190 Self::Internal {
191 message: message.into(),
192 source: None,
193 }
194 }
195
196 pub fn internal_with<E>(message: impl Into<String>, source: E) -> Self
197 where
198 E: std::error::Error + Send + Sync + 'static,
199 {
200 Self::Internal {
201 message: message.into(),
202 source: Some(Box::new(source)),
203 }
204 }
205
206 #[must_use]
210 pub fn suggested_status(&self) -> u16 {
211 match self {
212 Self::InvalidInput { .. } => 400,
213 Self::Unauthorized { .. } => 401,
214 Self::NotFound { .. } => 404,
215 Self::RateLimited { .. } => 429,
216 Self::Internal { .. } => 500,
217 Self::Timeout { .. } => 504,
218 Self::Transient { .. } => 503,
219 }
220 }
221
222 #[must_use]
224 pub fn is_retryable(&self) -> bool {
225 matches!(
226 self,
227 Self::Transient { .. } | Self::RateLimited { .. } | Self::Timeout { .. }
228 )
229 }
230
231 #[must_use]
236 pub fn is_user_facing(&self) -> bool {
237 matches!(
238 self,
239 Self::InvalidInput { .. }
240 | Self::Unauthorized { .. }
241 | Self::NotFound { .. }
242 | Self::RateLimited { .. }
243 )
244 }
245
246 #[must_use]
249 pub fn retry_after(&self) -> Option<Duration> {
250 match self {
251 Self::RateLimited { retry_after, .. } => *retry_after,
252 _ => None,
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn suggested_status_codes() {
263 assert_eq!(PluginError::transient("x").suggested_status(), 503);
264 assert_eq!(PluginError::rate_limited(None).suggested_status(), 429);
265 assert_eq!(PluginError::timeout().suggested_status(), 504);
266 assert_eq!(PluginError::invalid_input("x").suggested_status(), 400);
267 assert_eq!(PluginError::unauthorized("x").suggested_status(), 401);
268 assert_eq!(PluginError::not_found("x").suggested_status(), 404);
269 assert_eq!(PluginError::internal("x").suggested_status(), 500);
270 }
271
272 #[test]
273 fn retryable_partition() {
274 assert!(PluginError::transient("x").is_retryable());
275 assert!(PluginError::rate_limited(None).is_retryable());
276 assert!(PluginError::timeout().is_retryable());
277 assert!(!PluginError::invalid_input("x").is_retryable());
278 assert!(!PluginError::unauthorized("x").is_retryable());
279 assert!(!PluginError::not_found("x").is_retryable());
280 assert!(!PluginError::internal("x").is_retryable());
281 }
282
283 #[test]
284 fn user_facing_partition() {
285 assert!(PluginError::invalid_input("x").is_user_facing());
286 assert!(PluginError::unauthorized("x").is_user_facing());
287 assert!(PluginError::not_found("x").is_user_facing());
288 assert!(PluginError::rate_limited(None).is_user_facing());
289 assert!(!PluginError::transient("x").is_user_facing());
290 assert!(!PluginError::timeout().is_user_facing());
291 assert!(!PluginError::internal("x").is_user_facing());
292 }
293
294 #[test]
295 fn source_chain_is_preserved() {
296 use std::error::Error;
297 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "peer reset");
298 let plugin_err = PluginError::transient_with("HTTP request failed", io_err);
299
300 assert!(plugin_err.to_string().contains("HTTP request failed"));
302
303 let cause = plugin_err.source().expect("source must be preserved");
305 assert!(cause.to_string().contains("peer reset"));
306 }
307
308 #[test]
309 fn retry_after_only_set_for_rate_limited() {
310 assert_eq!(
311 PluginError::rate_limited(Some(Duration::from_secs(5))).retry_after(),
312 Some(Duration::from_secs(5))
313 );
314 assert_eq!(PluginError::rate_limited(None).retry_after(), None);
315 assert_eq!(PluginError::transient("x").retry_after(), None);
316 assert_eq!(PluginError::timeout().retry_after(), None);
317 }
318}