Skip to main content

atman_runtime/
error.rs

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}