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