Skip to main content

converge_provider/
error.rs

1// Copyright 2024-2026 Reflective Labs
2
3// SPDX-License-Identifier: MIT
4
5//! Generic backend error types.
6//!
7//! These errors are backend-agnostic. Any backend kind (LLM, policy,
8//! optimization, analytics) uses the same error structure, making
9//! error handling uniform across the platform.
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::capability::Capability;
15
16/// Error from any backend operation.
17///
18/// This is the universal error type for all backends. It captures the error
19/// kind, a human-readable message, and whether the operation can be retried.
20///
21/// # Retryable Errors
22///
23/// Some errors are transient (network issues, rate limits) and can be retried.
24/// Use [`is_retryable()`](BackendError::is_retryable) to check.
25///
26/// # Example
27///
28/// ```
29/// use converge_provider::{BackendError, BackendErrorKind};
30///
31/// let err = BackendError::new(BackendErrorKind::Timeout, "operation timed out");
32/// assert!(err.is_retryable());
33///
34/// let err = BackendError::new(BackendErrorKind::InvalidRequest, "missing field");
35/// assert!(!err.is_retryable());
36/// ```
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
38#[error("{kind}: {message}")]
39pub struct BackendError {
40    /// Error category.
41    pub kind: BackendErrorKind,
42    /// Human-readable description.
43    pub message: String,
44    /// Whether this operation can be retried.
45    pub retryable: bool,
46}
47
48impl BackendError {
49    /// Creates a new backend error with automatic retryable detection.
50    #[must_use]
51    pub fn new(kind: BackendErrorKind, message: impl Into<String>) -> Self {
52        let retryable = kind.is_retryable();
53        Self {
54            kind,
55            message: message.into(),
56            retryable,
57        }
58    }
59
60    /// Creates a new backend error with explicit retryable flag.
61    #[must_use]
62    pub fn with_retryable(
63        kind: BackendErrorKind,
64        message: impl Into<String>,
65        retryable: bool,
66    ) -> Self {
67        Self {
68            kind,
69            message: message.into(),
70            retryable,
71        }
72    }
73
74    /// Whether this error can be retried.
75    #[must_use]
76    pub fn is_retryable(&self) -> bool {
77        self.retryable
78    }
79
80    // ── Convenience constructors ──────────────────────────────────────
81
82    /// Authentication or authorization failure.
83    #[must_use]
84    pub fn auth(message: impl Into<String>) -> Self {
85        Self::new(BackendErrorKind::Authentication, message)
86    }
87
88    /// Rate limit or quota exceeded.
89    #[must_use]
90    pub fn rate_limit(message: impl Into<String>) -> Self {
91        Self::new(BackendErrorKind::RateLimit, message)
92    }
93
94    /// Invalid request parameters.
95    #[must_use]
96    pub fn invalid_request(message: impl Into<String>) -> Self {
97        Self::new(BackendErrorKind::InvalidRequest, message)
98    }
99
100    /// Backend not available.
101    #[must_use]
102    pub fn unavailable(message: impl Into<String>) -> Self {
103        Self::new(BackendErrorKind::Unavailable, message)
104    }
105
106    /// Network or connection error.
107    #[must_use]
108    pub fn network(message: impl Into<String>) -> Self {
109        Self::new(BackendErrorKind::Network, message)
110    }
111
112    /// Backend returned an error.
113    #[must_use]
114    pub fn backend(message: impl Into<String>) -> Self {
115        Self::new(BackendErrorKind::BackendError, message)
116    }
117
118    /// Response could not be parsed.
119    #[must_use]
120    pub fn parse(message: impl Into<String>) -> Self {
121        Self::new(BackendErrorKind::ParseError, message)
122    }
123
124    /// Operation timed out.
125    #[must_use]
126    pub fn timeout(message: impl Into<String>) -> Self {
127        Self::new(BackendErrorKind::Timeout, message)
128    }
129
130    /// Capability not supported.
131    #[must_use]
132    pub fn unsupported(capability: &Capability) -> Self {
133        Self::new(
134            BackendErrorKind::UnsupportedCapability,
135            format!("capability not supported: {capability}"),
136        )
137    }
138
139    /// Resource exhausted (budget, memory, etc.).
140    #[must_use]
141    pub fn resource_exhausted(message: impl Into<String>) -> Self {
142        Self::new(BackendErrorKind::ResourceExhausted, message)
143    }
144}
145
146/// Kind of backend error.
147///
148/// These categories are universal across all backend types.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150pub enum BackendErrorKind {
151    /// Authentication or authorization failure.
152    Authentication,
153    /// Rate limit or quota exceeded.
154    RateLimit,
155    /// Invalid request parameters.
156    InvalidRequest,
157    /// Backend not available or not found.
158    Unavailable,
159    /// Network or connection error.
160    Network,
161    /// Backend returned an error.
162    BackendError,
163    /// Response could not be parsed.
164    ParseError,
165    /// Operation timed out.
166    Timeout,
167    /// Capability not supported by this backend.
168    UnsupportedCapability,
169    /// Resource exhausted (budget, memory, compute).
170    ResourceExhausted,
171    /// Configuration error.
172    Configuration,
173}
174
175impl BackendErrorKind {
176    /// Whether errors of this kind are typically retryable.
177    #[must_use]
178    pub fn is_retryable(self) -> bool {
179        matches!(
180            self,
181            Self::RateLimit | Self::Unavailable | Self::Network | Self::Timeout
182        )
183    }
184}
185
186impl std::fmt::Display for BackendErrorKind {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            Self::Authentication => write!(f, "authentication"),
190            Self::RateLimit => write!(f, "rate_limit"),
191            Self::InvalidRequest => write!(f, "invalid_request"),
192            Self::Unavailable => write!(f, "unavailable"),
193            Self::Network => write!(f, "network"),
194            Self::BackendError => write!(f, "backend_error"),
195            Self::ParseError => write!(f, "parse_error"),
196            Self::Timeout => write!(f, "timeout"),
197            Self::UnsupportedCapability => write!(f, "unsupported_capability"),
198            Self::ResourceExhausted => write!(f, "resource_exhausted"),
199            Self::Configuration => write!(f, "configuration"),
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn new_sets_retryable_from_kind() {
210        let err = BackendError::new(BackendErrorKind::RateLimit, "slow down");
211        assert_eq!(err.kind, BackendErrorKind::RateLimit);
212        assert_eq!(err.message, "slow down");
213        assert!(err.is_retryable());
214
215        let err = BackendError::new(BackendErrorKind::InvalidRequest, "bad");
216        assert!(!err.is_retryable());
217    }
218
219    #[test]
220    fn with_retryable_overrides_default() {
221        let err = BackendError::with_retryable(BackendErrorKind::InvalidRequest, "retry me", true);
222        assert!(err.is_retryable());
223
224        let err = BackendError::with_retryable(BackendErrorKind::RateLimit, "no retry", false);
225        assert!(!err.is_retryable());
226    }
227
228    #[test]
229    fn convenience_auth() {
230        let err = BackendError::auth("denied");
231        assert_eq!(err.kind, BackendErrorKind::Authentication);
232        assert!(!err.is_retryable());
233    }
234
235    #[test]
236    fn convenience_rate_limit() {
237        let err = BackendError::rate_limit("quota exceeded");
238        assert_eq!(err.kind, BackendErrorKind::RateLimit);
239        assert!(err.is_retryable());
240    }
241
242    #[test]
243    fn convenience_invalid_request() {
244        let err = BackendError::invalid_request("missing field");
245        assert_eq!(err.kind, BackendErrorKind::InvalidRequest);
246        assert!(!err.is_retryable());
247    }
248
249    #[test]
250    fn convenience_unavailable() {
251        let err = BackendError::unavailable("down");
252        assert_eq!(err.kind, BackendErrorKind::Unavailable);
253        assert!(err.is_retryable());
254    }
255
256    #[test]
257    fn convenience_network() {
258        let err = BackendError::network("connection refused");
259        assert_eq!(err.kind, BackendErrorKind::Network);
260        assert!(err.is_retryable());
261    }
262
263    #[test]
264    fn convenience_backend() {
265        let err = BackendError::backend("500");
266        assert_eq!(err.kind, BackendErrorKind::BackendError);
267        assert!(!err.is_retryable());
268    }
269
270    #[test]
271    fn convenience_parse() {
272        let err = BackendError::parse("invalid json");
273        assert_eq!(err.kind, BackendErrorKind::ParseError);
274        assert!(!err.is_retryable());
275    }
276
277    #[test]
278    fn convenience_timeout() {
279        let err = BackendError::timeout("10s elapsed");
280        assert_eq!(err.kind, BackendErrorKind::Timeout);
281        assert!(err.is_retryable());
282    }
283
284    #[test]
285    fn convenience_unsupported() {
286        let err = BackendError::unsupported(&Capability::ImageUnderstanding);
287        assert_eq!(err.kind, BackendErrorKind::UnsupportedCapability);
288        assert!(!err.is_retryable());
289        assert!(err.message.contains("ImageUnderstanding"));
290    }
291
292    #[test]
293    fn convenience_resource_exhausted() {
294        let err = BackendError::resource_exhausted("out of memory");
295        assert_eq!(err.kind, BackendErrorKind::ResourceExhausted);
296        assert!(!err.is_retryable());
297    }
298
299    #[test]
300    fn kind_is_retryable() {
301        assert!(!BackendErrorKind::Authentication.is_retryable());
302        assert!(BackendErrorKind::RateLimit.is_retryable());
303        assert!(!BackendErrorKind::InvalidRequest.is_retryable());
304        assert!(BackendErrorKind::Unavailable.is_retryable());
305        assert!(BackendErrorKind::Network.is_retryable());
306        assert!(!BackendErrorKind::BackendError.is_retryable());
307        assert!(!BackendErrorKind::ParseError.is_retryable());
308        assert!(BackendErrorKind::Timeout.is_retryable());
309        assert!(!BackendErrorKind::UnsupportedCapability.is_retryable());
310        assert!(!BackendErrorKind::ResourceExhausted.is_retryable());
311        assert!(!BackendErrorKind::Configuration.is_retryable());
312    }
313
314    #[test]
315    fn kind_display() {
316        assert_eq!(
317            BackendErrorKind::Authentication.to_string(),
318            "authentication"
319        );
320        assert_eq!(BackendErrorKind::RateLimit.to_string(), "rate_limit");
321        assert_eq!(
322            BackendErrorKind::InvalidRequest.to_string(),
323            "invalid_request"
324        );
325        assert_eq!(BackendErrorKind::Unavailable.to_string(), "unavailable");
326        assert_eq!(BackendErrorKind::Network.to_string(), "network");
327        assert_eq!(BackendErrorKind::BackendError.to_string(), "backend_error");
328        assert_eq!(BackendErrorKind::ParseError.to_string(), "parse_error");
329        assert_eq!(BackendErrorKind::Timeout.to_string(), "timeout");
330        assert_eq!(
331            BackendErrorKind::UnsupportedCapability.to_string(),
332            "unsupported_capability"
333        );
334        assert_eq!(
335            BackendErrorKind::ResourceExhausted.to_string(),
336            "resource_exhausted"
337        );
338        assert_eq!(BackendErrorKind::Configuration.to_string(), "configuration");
339    }
340
341    #[test]
342    fn backend_error_display() {
343        let err = BackendError::new(BackendErrorKind::Timeout, "operation timed out");
344        assert_eq!(err.to_string(), "timeout: operation timed out");
345    }
346}