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    #[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}