1use harn_lexer::Span;
2
3use super::VmValue;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ArityExpect {
10 Exact(usize),
12 Range { min: usize, max: usize },
14 AtLeast(usize),
17}
18
19impl std::fmt::Display for ArityExpect {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 ArityExpect::Exact(n) => write!(f, "{n}"),
23 ArityExpect::Range { min, max } => write!(f, "{min}..={max}"),
24 ArityExpect::AtLeast(n) => write!(f, "at least {n}"),
25 }
26 }
27}
28
29#[derive(Debug, Clone)]
30pub struct ArityMismatchError {
31 pub callee: String,
32 pub expected: ArityExpect,
33 pub got: usize,
34 pub span: Option<Span>,
35}
36
37#[derive(Debug, Clone)]
38pub struct ArgTypeMismatchError {
39 pub callee: String,
40 pub param: String,
41 pub expected: String,
42 pub got: &'static str,
43 pub span: Option<Span>,
44}
45
46#[derive(Debug, Clone)]
47pub enum VmError {
48 StackUnderflow,
49 StackOverflow,
50 UndefinedVariable(String),
51 UndefinedBuiltin(String),
52 ImmutableAssignment(String),
53 TypeError(String),
54 Runtime(String),
55 DivisionByZero,
56 Thrown(VmValue),
57 CategorizedError {
59 message: String,
60 category: ErrorCategory,
61 },
62 DaemonQueueFull {
63 daemon_id: String,
64 capacity: usize,
65 },
66 Return(VmValue),
67 InvalidInstruction(u8),
68 ArityMismatch(Box<ArityMismatchError>),
72 ArgTypeMismatch(Box<ArgTypeMismatchError>),
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum ErrorCategory {
83 Timeout,
85 Auth,
87 RateLimit,
89 Overloaded,
93 ServerError,
95 TransientNetwork,
98 SchemaValidation,
100 SchemaStreamAborted,
107 ToolError,
109 ToolRejected,
111 EgressBlocked,
113 Cancelled,
115 NotFound,
117 CircuitOpen,
119 BudgetExceeded,
121 Generic,
123}
124
125impl ErrorCategory {
126 pub fn as_str(&self) -> &'static str {
127 match self {
128 ErrorCategory::Timeout => "timeout",
129 ErrorCategory::Auth => "auth",
130 ErrorCategory::RateLimit => "rate_limit",
131 ErrorCategory::Overloaded => "overloaded",
132 ErrorCategory::ServerError => "server_error",
133 ErrorCategory::TransientNetwork => "transient_network",
134 ErrorCategory::SchemaValidation => "schema_validation",
135 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
136 ErrorCategory::ToolError => "tool_error",
137 ErrorCategory::ToolRejected => "tool_rejected",
138 ErrorCategory::EgressBlocked => "egress_blocked",
139 ErrorCategory::Cancelled => "cancelled",
140 ErrorCategory::NotFound => "not_found",
141 ErrorCategory::CircuitOpen => "circuit_open",
142 ErrorCategory::BudgetExceeded => "budget_exceeded",
143 ErrorCategory::Generic => "generic",
144 }
145 }
146
147 pub fn parse(s: &str) -> Self {
148 match s {
149 "timeout" => ErrorCategory::Timeout,
150 "auth" => ErrorCategory::Auth,
151 "rate_limit" => ErrorCategory::RateLimit,
152 "overloaded" => ErrorCategory::Overloaded,
153 "server_error" => ErrorCategory::ServerError,
154 "transient_network" => ErrorCategory::TransientNetwork,
155 "schema_validation" => ErrorCategory::SchemaValidation,
156 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
157 "tool_error" => ErrorCategory::ToolError,
158 "tool_rejected" => ErrorCategory::ToolRejected,
159 "egress_blocked" => ErrorCategory::EgressBlocked,
160 "cancelled" => ErrorCategory::Cancelled,
161 "not_found" => ErrorCategory::NotFound,
162 "circuit_open" => ErrorCategory::CircuitOpen,
163 "budget_exceeded" => ErrorCategory::BudgetExceeded,
164 _ => ErrorCategory::Generic,
165 }
166 }
167
168 pub fn is_transient(&self) -> bool {
172 matches!(
173 self,
174 ErrorCategory::Timeout
175 | ErrorCategory::RateLimit
176 | ErrorCategory::Overloaded
177 | ErrorCategory::ServerError
178 | ErrorCategory::TransientNetwork
179 )
180 }
181}
182
183pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
185 VmError::CategorizedError {
186 message: message.into(),
187 category,
188 }
189}
190
191pub fn error_to_category(err: &VmError) -> ErrorCategory {
200 match err {
201 VmError::CategorizedError { category, .. } => category.clone(),
202 VmError::Thrown(VmValue::Dict(d)) => d
203 .get("category")
204 .map(|v| ErrorCategory::parse(&v.display()))
205 .unwrap_or(ErrorCategory::Generic),
206 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
207 VmError::Runtime(msg) => classify_error_message(msg),
208 _ => ErrorCategory::Generic,
209 }
210}
211
212pub fn classify_error_message(msg: &str) -> ErrorCategory {
215 if let Some(cat) = classify_by_http_status(msg) {
217 return cat;
218 }
219 let lower = msg.to_lowercase();
222 if lower.contains("cancelled") || lower.contains("canceled") {
223 return ErrorCategory::Cancelled;
224 }
225 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
226 return ErrorCategory::Timeout;
227 }
228 if msg.contains("overloaded_error") {
229 return ErrorCategory::Overloaded;
231 }
232 if msg.contains("api_error") {
233 return ErrorCategory::ServerError;
235 }
236 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
237 return ErrorCategory::RateLimit;
239 }
240 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
241 return ErrorCategory::Auth;
242 }
243 if msg.contains("not_found_error") || msg.contains("model_not_found") {
244 return ErrorCategory::NotFound;
245 }
246 if msg.contains("circuit_open") {
247 return ErrorCategory::CircuitOpen;
248 }
249 if lower.contains("connection reset")
251 || lower.contains("connection refused")
252 || lower.contains("connection closed")
253 || lower.contains("broken pipe")
254 || lower.contains("dns error")
255 || lower.contains("stream error")
256 || lower.contains("unexpected eof")
257 {
258 return ErrorCategory::TransientNetwork;
259 }
260 ErrorCategory::Generic
261}
262
263fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
267 for code in extract_http_status_codes(msg) {
270 return Some(match code {
271 401 | 403 => ErrorCategory::Auth,
272 404 | 410 => ErrorCategory::NotFound,
273 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
274 429 => ErrorCategory::RateLimit,
275 503 | 529 => ErrorCategory::Overloaded,
276 500 | 502 => ErrorCategory::ServerError,
277 _ => continue,
278 });
279 }
280 None
281}
282
283fn extract_http_status_codes(msg: &str) -> Vec<u16> {
285 let mut codes = Vec::new();
286 let bytes = msg.as_bytes();
287 for i in 0..bytes.len().saturating_sub(2) {
288 if bytes[i].is_ascii_digit()
290 && bytes[i + 1].is_ascii_digit()
291 && bytes[i + 2].is_ascii_digit()
292 {
293 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
295 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
296 if before_ok && after_ok {
297 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
298 if (400..=599).contains(&code) {
299 codes.push(code);
300 }
301 }
302 }
303 }
304 }
305 codes
306}
307
308impl std::fmt::Display for VmError {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 match self {
311 VmError::StackUnderflow => write!(f, "Stack underflow"),
312 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
313 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
314 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
315 VmError::ImmutableAssignment(n) => {
316 write!(f, "Cannot assign to immutable binding: {n}")
317 }
318 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
319 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
320 VmError::DivisionByZero => write!(f, "Division by zero"),
321 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
322 VmError::CategorizedError { message, category } => {
323 write!(f, "Error [{}]: {}", category.as_str(), message)
324 }
325 VmError::DaemonQueueFull {
326 daemon_id,
327 capacity,
328 } => write!(
329 f,
330 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
331 ),
332 VmError::Return(_) => write!(f, "Return from function"),
333 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
334 VmError::ArityMismatch(err) => {
335 let arg_word = match err.expected {
336 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
337 _ => "arguments",
338 };
339 write!(
340 f,
341 "Arity mismatch: '{}' expects {} {}, got {}{}",
342 err.callee,
343 err.expected,
344 arg_word,
345 err.got,
346 fmt_span_suffix(&err.span)
347 )
348 }
349 VmError::ArgTypeMismatch(err) => {
350 write!(
351 f,
352 "Type error: '{}' parameter `{}` expects {}, got {}{}",
353 err.callee,
354 err.param,
355 err.expected,
356 err.got,
357 fmt_span_suffix(&err.span)
358 )
359 }
360 }
361 }
362}
363
364fn fmt_span_suffix(span: &Option<Span>) -> String {
365 match span {
366 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
367 None => String::new(),
368 }
369}
370
371impl std::error::Error for VmError {}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn classifies_cancelled_messages() {
379 assert_eq!(
380 classify_error_message("Bridge: operation cancelled"),
381 ErrorCategory::Cancelled
382 );
383 assert_eq!(
384 classify_error_message("operation canceled by host"),
385 ErrorCategory::Cancelled
386 );
387 }
388}