Skip to main content

chat_engine_sdk/
error.rs

1use std::time::Duration;
2
3use thiserror::Error;
4
5/// Boxed dynamic error for preserving the underlying cause chain across the
6/// SDK boundary. Implements `std::error::Error` so callers can walk the chain
7/// via `Error::source()`.
8pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
9
10/// Errors returned by `ChatEngineBackendPlugin` methods.
11///
12/// Each variant has a clear contract for how Chat Engine reacts when it sees
13/// it (HTTP status to surface, retry policy, whether details may be shown to
14/// the end user). Use the constructors (`PluginError::transient(...)`,
15/// `PluginError::invalid_input_with(..., source)` …) to build values; they
16/// preserve the original error chain via the `#[source]` field.
17///
18/// Routing matrix:
19///
20/// | Variant         | HTTP | Retry?  | User-facing? | Typical cause                            |
21/// |-----------------|------|---------|--------------|------------------------------------------|
22/// | `Transient`     | 503  | yes     | no           | network blip, upstream 5xx               |
23/// | `RateLimited`   | 429  | yes     | yes          | upstream `Retry-After` / 429             |
24/// | `Timeout`       | 504  | yes     | no           | request exceeded the deadline            |
25/// | `InvalidInput`  | 400  | no      | yes          | bad request payload, validation failure  |
26/// | `Unauthorized`  | 401  | no      | yes          | auth token missing/expired/insufficient  |
27/// | `NotFound`      | 404  | no      | yes          | model / resource does not exist          |
28/// | `Internal`      | 500  | no      | no (page on-call) | misconfiguration, plugin bug         |
29#[derive(Debug, Error)]
30pub enum PluginError {
31    /// Retryable transient failure (network blip, upstream 5xx, connection reset).
32    #[error("transient error: {message}")]
33    Transient {
34        message: String,
35        #[source]
36        source: Option<BoxError>,
37    },
38
39    /// Upstream rate-limited the request. Retry after the suggested duration if any.
40    #[error("rate limited")]
41    RateLimited {
42        retry_after: Option<Duration>,
43        #[source]
44        source: Option<BoxError>,
45    },
46
47    /// Request exceeded the deadline (or upstream timed out).
48    #[error("timeout")]
49    Timeout {
50        #[source]
51        source: Option<BoxError>,
52    },
53
54    /// Client-side error: malformed input, validation failure. Surface to user.
55    #[error("invalid input: {message}")]
56    InvalidInput {
57        message: String,
58        #[source]
59        source: Option<BoxError>,
60    },
61
62    /// Authentication or authorization failure. Surface to user.
63    #[error("unauthorized: {message}")]
64    Unauthorized {
65        message: String,
66        #[source]
67        source: Option<BoxError>,
68    },
69
70    /// Resource not found (model, file, session, …). Surface to user.
71    #[error("not found: {message}")]
72    NotFound {
73        message: String,
74        #[source]
75        source: Option<BoxError>,
76    },
77
78    /// Operator-side error: misconfiguration, plugin bug, internal invariant
79    /// violation. Hide details from end users; page on-call.
80    #[error("internal error: {message}")]
81    Internal {
82        message: String,
83        #[source]
84        source: Option<BoxError>,
85    },
86}
87
88impl PluginError {
89    // -------------- Convenience constructors --------------
90
91    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    // -------------- Routing helpers --------------
207
208    /// Suggested HTTP status code when this error surfaces to a client.
209    #[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    /// Whether Chat Engine should retry the operation (with backoff).
223    #[must_use]
224    pub fn is_retryable(&self) -> bool {
225        matches!(
226            self,
227            Self::Transient { .. } | Self::RateLimited { .. } | Self::Timeout { .. }
228        )
229    }
230
231    /// True if the error's message is safe to surface to the end user.
232    /// User-actionable errors (`InvalidInput`, `Unauthorized`, `NotFound`,
233    /// `RateLimited`) describe user mistakes; the rest may leak operator
234    /// details and must be replaced by a generic message at the boundary.
235    #[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    /// `Retry-After` hint if the variant carries one (currently only
247    /// `RateLimited`). Returns `None` for variants without an explicit hint.
248    #[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        // Top-level message
301        assert!(plugin_err.to_string().contains("HTTP request failed"));
302
303        // Walk the source chain
304        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}