Skip to main content

edgequake_llm/providers/vscode/
error.rs

1//! Error types for VSCode Copilot provider.
2//!
3//! # Design Rationale
4//!
5//! WHY: We use a dedicated `VsCodeError` type rather than the generic `LlmError` because:
6//! 1. VSCode/Copilot has specific error conditions (proxy unavailable, rate limiting)
7//! 2. Error messages can include actionable hints (e.g., "Is copilot-api running?")
8//! 3. Conversion to `LlmError` is automatic via `From` trait
9//!
10//! # Error Categories
11//!
12//! ```text
13//! ┌─────────────────────────────────────────────────────────────────┐
14//! │                    VsCodeError Types                             │
15//! ├─────────────────────────────────────────────────────────────────┤
16//! │                                                                   │
17//! │  Initialization Errors                                           │
18//! │  ├── ClientInit      → HTTP client TLS/config issues            │
19//! │  └── ProxyUnavailable → Proxy server not running                │
20//! │                                                                   │
21//! │  Runtime Errors                                                   │
22//! │  ├── Network         → DNS, timeout, connection refused         │
23//! │  ├── Authentication  → Invalid/expired token                    │
24//! │  ├── RateLimited     → 429 Too Many Requests                    │
25//! │  ├── InvalidRequest  → 400 Bad Request                          │
26//! │  └── ServiceUnavailable → 503 Service Unavailable               │
27//! │                                                                   │
28//! │  Response Errors                                                  │
29//! │  ├── ApiError        → Generic API error (500, etc.)            │
30//! │  ├── Decode          → JSON deserialization failed              │
31//! │  └── Stream          → SSE parsing error                        │
32//! │                                                                   │
33//! └─────────────────────────────────────────────────────────────────┘
34//! ```
35//!
36//! # Error Recovery
37//!
38//! | Error | Retryable | Action |
39//! |-------|-----------|--------|
40//! | `RateLimited` | Yes | Wait with exponential backoff |
41//! | `Network` | Yes | Retry after delay |
42//! | `ServiceUnavailable` | Yes | Retry after delay |
43//! | `Authentication` | No | Re-authenticate |
44//! | `InvalidRequest` | No | Fix request parameters |
45//! | `ClientInit` | No | Fix configuration |
46
47use thiserror::Error;
48
49pub type Result<T> = std::result::Result<T, VsCodeError>;
50
51/// VSCode Copilot provider errors.
52#[derive(Error, Debug)]
53pub enum VsCodeError {
54    /// Failed to initialize HTTP client.
55    #[error("Failed to initialize client: {0}")]
56    ClientInit(String),
57
58    /// Proxy server is unavailable or not responding.
59    #[error("Proxy unavailable: {0}. Is copilot-api running on localhost:4141?")]
60    ProxyUnavailable(String),
61
62    /// Network communication error.
63    #[error("Network error: {0}")]
64    Network(String),
65
66    /// Authentication or authorization failed.
67    #[error("Authentication failed: {0}")]
68    Authentication(String),
69
70    /// Rate limit exceeded.
71    #[error("Rate limited. Try again later.")]
72    RateLimited,
73
74    /// Invalid request format or parameters.
75    #[error("Invalid request: {0}")]
76    InvalidRequest(String),
77
78    /// Service temporarily unavailable.
79    #[error("Service unavailable")]
80    ServiceUnavailable,
81
82    /// Generic API error.
83    #[error("API error: {0}")]
84    ApiError(String),
85
86    /// Failed to decode response.
87    #[error("Failed to decode response: {0}")]
88    Decode(String),
89
90    /// Streaming error.
91    #[error("Stream error: {0}")]
92    Stream(String),
93}
94
95impl VsCodeError {
96    /// Returns true if this error is retryable.
97    ///
98    /// # WHY
99    ///
100    /// Consumers of this API need to know which errors warrant retry attempts
101    /// versus which errors indicate permanent failures. This method encapsulates
102    /// that knowledge so callers don't need to match on error variants.
103    ///
104    /// # Retryable Errors
105    ///
106    /// - `Network`: Temporary connectivity issues (DNS, timeout, connection refused)
107    /// - `RateLimited`: 429 response - will succeed after backoff
108    /// - `ServiceUnavailable`: 503 response - server temporarily down
109    ///
110    /// # Non-Retryable Errors
111    ///
112    /// - `ClientInit`: Configuration issue - won't resolve with retry
113    /// - `ProxyUnavailable`: Proxy needs to be started
114    /// - `Authentication`: Token invalid - need new token
115    /// - `InvalidRequest`: Request parameters wrong - fix before retry
116    /// - `ApiError`: Permanent server-side failure
117    /// - `Decode`: Response format issue - server bug or version mismatch
118    /// - `Stream`: SSE parsing error - unlikely to resolve
119    ///
120    /// # Example
121    ///
122    /// ```rust
123    /// use edgequake_llm::providers::vscode::VsCodeError;
124    ///
125    /// let err = VsCodeError::RateLimited;
126    /// if err.is_retryable() {
127    ///     // Apply exponential backoff and retry
128    /// }
129    /// ```
130    pub fn is_retryable(&self) -> bool {
131        matches!(
132            self,
133            VsCodeError::Network(_) | VsCodeError::RateLimited | VsCodeError::ServiceUnavailable
134        )
135    }
136}
137
138// Convert VsCodeError to LlmError
139impl From<VsCodeError> for crate::error::LlmError {
140    fn from(err: VsCodeError) -> Self {
141        match err {
142            VsCodeError::ClientInit(msg) => Self::ConfigError(msg),
143            VsCodeError::ProxyUnavailable(msg) => Self::NetworkError(msg),
144            VsCodeError::Network(msg) => Self::NetworkError(msg),
145            VsCodeError::Authentication(msg) => Self::AuthError(msg),
146            VsCodeError::RateLimited => Self::RateLimited("Rate limit exceeded".to_string()),
147            VsCodeError::InvalidRequest(msg) => Self::InvalidRequest(msg),
148            VsCodeError::ServiceUnavailable => {
149                Self::NetworkError("Service unavailable".to_string())
150            }
151            VsCodeError::ApiError(msg) => Self::ApiError(msg),
152            VsCodeError::Decode(msg) => Self::ApiError(format!("Decode: {}", msg)),
153            VsCodeError::Stream(msg) => Self::ApiError(format!("Stream: {}", msg)),
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::error::LlmError;
162
163    // ========================================================================
164    // Display Trait Tests - Verify Error Messages
165    // ========================================================================
166
167    #[test]
168    fn test_vscode_error_display_client_init() {
169        let err = VsCodeError::ClientInit("TLS handshake failed".to_string());
170        let msg = err.to_string();
171        assert!(msg.contains("Failed to initialize client"));
172        assert!(msg.contains("TLS handshake failed"));
173    }
174
175    #[test]
176    fn test_vscode_error_display_proxy_unavailable() {
177        let err = VsCodeError::ProxyUnavailable("connection refused".to_string());
178        let msg = err.to_string();
179        assert!(msg.contains("Proxy unavailable"));
180        assert!(msg.contains("connection refused"));
181        assert!(msg.contains("localhost:4141")); // Helpful hint
182    }
183
184    #[test]
185    fn test_vscode_error_display_network() {
186        let err = VsCodeError::Network("timeout after 30s".to_string());
187        assert_eq!(err.to_string(), "Network error: timeout after 30s");
188    }
189
190    #[test]
191    fn test_vscode_error_display_authentication() {
192        let err = VsCodeError::Authentication("token expired".to_string());
193        assert_eq!(err.to_string(), "Authentication failed: token expired");
194    }
195
196    #[test]
197    fn test_vscode_error_display_rate_limited() {
198        let err = VsCodeError::RateLimited;
199        assert_eq!(err.to_string(), "Rate limited. Try again later.");
200    }
201
202    #[test]
203    fn test_vscode_error_display_service_unavailable() {
204        let err = VsCodeError::ServiceUnavailable;
205        assert_eq!(err.to_string(), "Service unavailable");
206    }
207
208    // ========================================================================
209    // From<VsCodeError> for LlmError Conversion Tests
210    // ========================================================================
211
212    #[test]
213    fn test_conversion_client_init_to_config_error() {
214        let vscode_err = VsCodeError::ClientInit("init failed".to_string());
215        let llm_err: LlmError = vscode_err.into();
216
217        match llm_err {
218            LlmError::ConfigError(msg) => assert_eq!(msg, "init failed"),
219            other => panic!("Expected ConfigError, got {:?}", other),
220        }
221    }
222
223    #[test]
224    fn test_conversion_proxy_unavailable_to_network_error() {
225        let vscode_err = VsCodeError::ProxyUnavailable("refused".to_string());
226        let llm_err: LlmError = vscode_err.into();
227
228        match llm_err {
229            LlmError::NetworkError(msg) => assert_eq!(msg, "refused"),
230            other => panic!("Expected NetworkError, got {:?}", other),
231        }
232    }
233
234    #[test]
235    fn test_conversion_network_to_network_error() {
236        let vscode_err = VsCodeError::Network("dns lookup failed".to_string());
237        let llm_err: LlmError = vscode_err.into();
238
239        match llm_err {
240            LlmError::NetworkError(msg) => assert_eq!(msg, "dns lookup failed"),
241            other => panic!("Expected NetworkError, got {:?}", other),
242        }
243    }
244
245    #[test]
246    fn test_conversion_authentication_to_auth_error() {
247        let vscode_err = VsCodeError::Authentication("invalid token".to_string());
248        let llm_err: LlmError = vscode_err.into();
249
250        match llm_err {
251            LlmError::AuthError(msg) => assert_eq!(msg, "invalid token"),
252            other => panic!("Expected AuthError, got {:?}", other),
253        }
254    }
255
256    #[test]
257    fn test_conversion_rate_limited() {
258        let vscode_err = VsCodeError::RateLimited;
259        let llm_err: LlmError = vscode_err.into();
260
261        match llm_err {
262            LlmError::RateLimited(msg) => assert!(msg.contains("Rate limit")),
263            other => panic!("Expected RateLimited, got {:?}", other),
264        }
265    }
266
267    #[test]
268    fn test_conversion_invalid_request() {
269        let vscode_err = VsCodeError::InvalidRequest("missing model".to_string());
270        let llm_err: LlmError = vscode_err.into();
271
272        match llm_err {
273            LlmError::InvalidRequest(msg) => assert_eq!(msg, "missing model"),
274            other => panic!("Expected InvalidRequest, got {:?}", other),
275        }
276    }
277
278    #[test]
279    fn test_conversion_service_unavailable() {
280        let vscode_err = VsCodeError::ServiceUnavailable;
281        let llm_err: LlmError = vscode_err.into();
282
283        match llm_err {
284            LlmError::NetworkError(msg) => assert!(msg.contains("unavailable")),
285            other => panic!("Expected NetworkError, got {:?}", other),
286        }
287    }
288
289    #[test]
290    fn test_conversion_api_error() {
291        let vscode_err = VsCodeError::ApiError("internal server error".to_string());
292        let llm_err: LlmError = vscode_err.into();
293
294        match llm_err {
295            LlmError::ApiError(msg) => assert_eq!(msg, "internal server error"),
296            other => panic!("Expected ApiError, got {:?}", other),
297        }
298    }
299
300    #[test]
301    fn test_conversion_decode_error() {
302        let vscode_err = VsCodeError::Decode("invalid JSON".to_string());
303        let llm_err: LlmError = vscode_err.into();
304
305        match llm_err {
306            LlmError::ApiError(msg) => {
307                assert!(msg.contains("Decode"));
308                assert!(msg.contains("invalid JSON"));
309            }
310            other => panic!("Expected ApiError, got {:?}", other),
311        }
312    }
313
314    #[test]
315    fn test_conversion_stream_error() {
316        let vscode_err = VsCodeError::Stream("connection reset".to_string());
317        let llm_err: LlmError = vscode_err.into();
318
319        match llm_err {
320            LlmError::ApiError(msg) => {
321                assert!(msg.contains("Stream"));
322                assert!(msg.contains("connection reset"));
323            }
324            other => panic!("Expected ApiError, got {:?}", other),
325        }
326    }
327
328    // ========================================================================
329    // is_retryable() Tests
330    // WHY: Verify correct categorization of retryable vs non-retryable errors
331    // ========================================================================
332
333    #[test]
334    fn test_is_retryable_network_error() {
335        // WHY: Network errors are temporary and should be retried
336        let err = VsCodeError::Network("connection timeout".to_string());
337        assert!(err.is_retryable(), "Network errors should be retryable");
338    }
339
340    #[test]
341    fn test_is_retryable_rate_limited() {
342        // WHY: 429 response means we should wait and retry
343        let err = VsCodeError::RateLimited;
344        assert!(
345            err.is_retryable(),
346            "Rate limited errors should be retryable"
347        );
348    }
349
350    #[test]
351    fn test_is_retryable_service_unavailable() {
352        // WHY: 503 means server is temporarily down
353        let err = VsCodeError::ServiceUnavailable;
354        assert!(
355            err.is_retryable(),
356            "Service unavailable should be retryable"
357        );
358    }
359
360    #[test]
361    fn test_is_not_retryable_auth_error() {
362        // WHY: Auth errors need new credentials, not retry
363        let err = VsCodeError::Authentication("token expired".to_string());
364        assert!(!err.is_retryable(), "Auth errors should not be retryable");
365    }
366
367    #[test]
368    fn test_is_not_retryable_invalid_request() {
369        // WHY: Invalid request needs to be fixed, not retried
370        let err = VsCodeError::InvalidRequest("missing model".to_string());
371        assert!(
372            !err.is_retryable(),
373            "Invalid request should not be retryable"
374        );
375    }
376
377    #[test]
378    fn test_is_not_retryable_client_init() {
379        // WHY: Client init errors are configuration issues
380        let err = VsCodeError::ClientInit("TLS failed".to_string());
381        assert!(
382            !err.is_retryable(),
383            "Client init errors should not be retryable"
384        );
385    }
386
387    #[test]
388    fn test_is_not_retryable_api_error() {
389        // WHY: Generic API errors (500) are typically permanent
390        let err = VsCodeError::ApiError("internal error".to_string());
391        assert!(!err.is_retryable(), "API errors should not be retryable");
392    }
393
394    #[test]
395    fn test_is_not_retryable_decode_error() {
396        // WHY: Decode errors indicate server response format issues
397        let err = VsCodeError::Decode("invalid JSON".to_string());
398        assert!(!err.is_retryable(), "Decode errors should not be retryable");
399    }
400}