1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum LlmError {
5 #[error("model error: {0}")]
6 Model(String),
7
8 #[error("no key found: {0}")]
9 NeedsKey(String),
10
11 #[error("provider error: {0}")]
12 Provider(String),
13
14 #[error("HTTP error {status}: {message}")]
15 HttpError { status: u16, message: String },
16
17 #[error("config error: {0}")]
18 Config(String),
19
20 #[error("io error: {0}")]
21 Io(#[from] std::io::Error),
22
23 #[error("store error: {0}")]
24 Store(String),
25}
26
27impl LlmError {
28 pub fn is_retryable(&self) -> bool {
31 matches!(self, LlmError::HttpError { status, .. }
32 if *status == 429 || (500..=599).contains(status))
33 }
34}
35
36pub type Result<T> = std::result::Result<T, LlmError>;
37
38#[cfg(test)]
39mod tests {
40 use super::*;
41
42 #[test]
43 fn error_display_model() {
44 let err = LlmError::Model("rate limited".into());
45 assert_eq!(err.to_string(), "model error: rate limited");
46 }
47
48 #[test]
49 fn error_display_needs_key() {
50 let err = LlmError::NeedsKey(
51 "No key found - set one with 'llm keys set openai'".into(),
52 );
53 assert!(err.to_string().contains("llm keys set openai"));
54 }
55
56 #[test]
57 fn error_display_provider() {
58 let err = LlmError::Provider("connection timeout".into());
59 assert_eq!(err.to_string(), "provider error: connection timeout");
60 }
61
62 #[test]
63 fn error_display_config() {
64 let err = LlmError::Config("invalid TOML".into());
65 assert_eq!(err.to_string(), "config error: invalid TOML");
66 }
67
68 #[test]
69 fn error_display_io() {
70 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
71 let err: LlmError = io_err.into();
72 assert!(err.to_string().contains("file not found"));
73 }
74
75 #[test]
76 fn error_display_store() {
77 let err = LlmError::Store("conversation not found".into());
78 assert_eq!(err.to_string(), "store error: conversation not found");
79 }
80
81 #[test]
82 fn error_io_from_conversion() {
83 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
84 let llm_err: LlmError = io_err.into();
85 assert!(matches!(llm_err, LlmError::Io(_)));
86 }
87
88 #[test]
89 fn error_is_send_sync() {
90 fn assert_send_sync<T: Send + Sync>() {}
91 assert_send_sync::<LlmError>();
92 }
93
94 #[test]
95 fn result_type_works() {
96 let ok: Result<i32> = Ok(42);
97 assert_eq!(ok.unwrap(), 42);
98
99 let err: Result<i32> = Err(LlmError::Config("bad".into()));
100 assert!(err.is_err());
101 }
102
103 #[test]
104 fn error_display_http() {
105 let err = LlmError::HttpError { status: 429, message: "rate limited".into() };
106 assert_eq!(err.to_string(), "HTTP error 429: rate limited");
107 }
108
109 #[test]
110 fn http_error_retryable_429() {
111 let err = LlmError::HttpError { status: 429, message: "rate limited".into() };
112 assert!(err.is_retryable());
113 }
114
115 #[test]
116 fn http_error_retryable_5xx() {
117 for status in [500, 502, 503, 504] {
118 let err = LlmError::HttpError { status, message: "server error".into() };
119 assert!(err.is_retryable(), "status {status} should be retryable");
120 }
121 }
122
123 #[test]
124 fn http_error_not_retryable_4xx() {
125 for status in [400, 401, 403, 404, 422] {
126 let err = LlmError::HttpError { status, message: "client error".into() };
127 assert!(!err.is_retryable(), "status {status} should not be retryable");
128 }
129 }
130
131 #[test]
132 fn non_http_errors_not_retryable() {
133 assert!(!LlmError::Provider("fail".into()).is_retryable());
134 assert!(!LlmError::Model("bad".into()).is_retryable());
135 assert!(!LlmError::NeedsKey("key".into()).is_retryable());
136 assert!(!LlmError::Config("cfg".into()).is_retryable());
137 assert!(!LlmError::Store("store".into()).is_retryable());
138 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "io");
139 assert!(!LlmError::Io(io_err).is_retryable());
140 }
141}