1use thiserror::Error;
2
3#[derive(Debug, Clone, Error)]
4pub enum RuntimeError {
5 #[error("undefined variable: {0}")]
6 UndefinedVar(String),
7
8 #[error("undefined tool: {0}")]
9 UndefinedTool(String),
10
11 #[error("type mismatch: expected {expected}, got {actual}")]
12 TypeMismatch { expected: String, actual: String },
13
14 #[error("missing argument: {0}")]
15 MissingArg(String),
16
17 #[error("tool failed: {0}")]
18 ToolFailed(String),
19
20 #[error("cancelled: {0}")]
21 Cancelled(String),
22
23 #[error("aborted: {0}")]
24 Aborted(String),
25
26 #[error("redirect to flow `{0}`")]
27 Redirect(String),
28
29 #[error("l2 restart: {correction_text}")]
30 L2Restart {
31 correction_text: String,
32 partial_output: String,
33 partial_tokens: u64,
34 },
35
36 #[error("attachment error: {reason}")]
37 AttachmentError { reason: String },
38
39 #[error("thinking signature missing")]
40 ThinkingSignatureMissing,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum ErrorKind {
45 Transient,
46 Timeout,
47 RateLimit,
48 AuthFailed,
49 ContentFilter,
50 InvalidRequest,
51 ProviderDown,
52 ToolError,
53 TypeMismatch,
54 MissingArg,
55 Cancelled,
56 UserError,
57 Internal,
58}
59
60impl ErrorKind {
61 pub fn as_str(&self) -> &'static str {
62 match self {
63 ErrorKind::Transient => "transient",
64 ErrorKind::Timeout => "timeout",
65 ErrorKind::RateLimit => "rate_limit",
66 ErrorKind::AuthFailed => "auth_failed",
67 ErrorKind::ContentFilter => "content_filter",
68 ErrorKind::InvalidRequest => "invalid_request",
69 ErrorKind::ProviderDown => "provider_down",
70 ErrorKind::ToolError => "tool_error",
71 ErrorKind::TypeMismatch => "type_mismatch",
72 ErrorKind::MissingArg => "missing_arg",
73 ErrorKind::Cancelled => "cancelled",
74 ErrorKind::UserError => "user_error",
75 ErrorKind::Internal => "internal",
76 }
77 }
78
79 pub fn from_name(name: &str) -> Option<Self> {
80 Some(match name {
81 "transient" => ErrorKind::Transient,
82 "timeout" => ErrorKind::Timeout,
83 "rate_limit" => ErrorKind::RateLimit,
84 "auth_failed" => ErrorKind::AuthFailed,
85 "content_filter" => ErrorKind::ContentFilter,
86 "invalid_request" => ErrorKind::InvalidRequest,
87 "provider_down" => ErrorKind::ProviderDown,
88 "tool_error" => ErrorKind::ToolError,
89 "type_mismatch" => ErrorKind::TypeMismatch,
90 "missing_arg" => ErrorKind::MissingArg,
91 "cancelled" => ErrorKind::Cancelled,
92 "user_error" => ErrorKind::UserError,
93 "internal" => ErrorKind::Internal,
94 _ => return None,
95 })
96 }
97}
98
99impl RuntimeError {
100 pub fn kind(&self) -> ErrorKind {
101 match self {
102 RuntimeError::UndefinedVar(_) => ErrorKind::InvalidRequest,
103 RuntimeError::UndefinedTool(_) => ErrorKind::InvalidRequest,
104 RuntimeError::TypeMismatch { .. } => ErrorKind::TypeMismatch,
105 RuntimeError::MissingArg(_) => ErrorKind::MissingArg,
106 RuntimeError::Cancelled(_) => ErrorKind::Cancelled,
107 RuntimeError::Aborted(_) => ErrorKind::UserError,
108 RuntimeError::Redirect(_) => ErrorKind::Cancelled,
109 RuntimeError::L2Restart { .. } => ErrorKind::UserError,
110 RuntimeError::ToolFailed(msg) => classify_tool_failed(msg),
111 RuntimeError::AttachmentError { .. } => ErrorKind::InvalidRequest,
112 RuntimeError::ThinkingSignatureMissing => ErrorKind::InvalidRequest,
113 }
114 }
115}
116
117fn classify_tool_failed(msg: &str) -> ErrorKind {
118 let m = msg.to_ascii_lowercase();
119 if m.contains("timeout") || m.contains("timed out") {
120 return ErrorKind::Timeout;
121 }
122 if m.contains("429") || m.contains("rate limit") || m.contains("rate-limit") {
123 return ErrorKind::RateLimit;
124 }
125 if m.contains(" 401")
126 || m.contains(" 403")
127 || m.contains("unauthorized")
128 || m.contains("forbidden")
129 {
130 return ErrorKind::AuthFailed;
131 }
132 if m.contains("content_filter")
133 || m.contains("content filter")
134 || m.contains("safety")
135 || m.contains("policy violation")
136 || m.contains("policy_violation")
137 {
138 return ErrorKind::ContentFilter;
139 }
140 if m.contains(" 500")
141 || m.contains(" 502")
142 || m.contains(" 503")
143 || m.contains(" 504")
144 || m.contains("upstream")
145 || m.contains("bad gateway")
146 || m.contains("service unavailable")
147 {
148 return ErrorKind::ProviderDown;
149 }
150 if m.contains("network")
151 || m.contains("connection")
152 || m.contains("connect ")
153 || m.contains("reset by peer")
154 {
155 return ErrorKind::Transient;
156 }
157 if m.contains(" 400") || m.contains("bad request") || m.contains("invalid request") {
158 return ErrorKind::InvalidRequest;
159 }
160 ErrorKind::ToolError
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn classify_covers_common_provider_error_strings() {
169 let cases: &[(&str, ErrorKind)] = &[
170 ("openai net: request timed out", ErrorKind::Timeout),
171 ("anthropic: 429 rate limit exceeded", ErrorKind::RateLimit),
172 ("openai http 401: unauthorized", ErrorKind::AuthFailed),
173 (
174 "anthropic http 400: content_filter block",
175 ErrorKind::ContentFilter,
176 ),
177 ("openai http 502 Bad Gateway", ErrorKind::ProviderDown),
178 ("hyper: connection reset by peer", ErrorKind::Transient),
179 (
180 "openai http 400: bad request schema",
181 ErrorKind::InvalidRequest,
182 ),
183 ("fs.read: no such file", ErrorKind::ToolError),
184 ];
185 for (msg, expected) in cases {
186 let err = RuntimeError::ToolFailed(msg.to_string());
187 assert_eq!(err.kind(), *expected, "input: {msg}");
188 }
189 }
190
191 #[test]
192 fn structural_variants_map_to_semantic_kinds() {
193 assert_eq!(
194 RuntimeError::TypeMismatch {
195 expected: "int".into(),
196 actual: "string".into()
197 }
198 .kind(),
199 ErrorKind::TypeMismatch
200 );
201 assert_eq!(
202 RuntimeError::MissingArg("model".into()).kind(),
203 ErrorKind::MissingArg
204 );
205 assert_eq!(
206 RuntimeError::Cancelled("user hit ctrl-c".into()).kind(),
207 ErrorKind::Cancelled
208 );
209 assert_eq!(
210 RuntimeError::Aborted("watch tripped".into()).kind(),
211 ErrorKind::UserError
212 );
213 assert_eq!(
214 RuntimeError::UndefinedTool("fs.nope".into()).kind(),
215 ErrorKind::InvalidRequest
216 );
217 }
218
219 #[test]
220 fn error_kind_round_trips_through_names() {
221 for k in [
222 ErrorKind::Transient,
223 ErrorKind::Timeout,
224 ErrorKind::RateLimit,
225 ErrorKind::AuthFailed,
226 ErrorKind::ContentFilter,
227 ErrorKind::InvalidRequest,
228 ErrorKind::ProviderDown,
229 ErrorKind::ToolError,
230 ErrorKind::TypeMismatch,
231 ErrorKind::MissingArg,
232 ErrorKind::Cancelled,
233 ErrorKind::UserError,
234 ErrorKind::Internal,
235 ] {
236 assert_eq!(ErrorKind::from_name(k.as_str()), Some(k));
237 }
238 assert_eq!(ErrorKind::from_name("nope"), None);
239 }
240}