Skip to main content

agentic_core/executor/
error.rs

1use http::StatusCode;
2use thiserror::Error;
3
4use crate::StorageError;
5use crate::tool::ToolError;
6use crate::utils::common::serialize_to_vec_or_default;
7
8#[non_exhaustive]
9#[derive(Debug, Error)]
10pub enum ExecutorError {
11    /// A storage layer operation failed.
12    #[error("storage error: {0}")]
13    Storage(#[from] StorageError),
14
15    /// Persistence failed after inference completed.
16    ///
17    /// The source is retained for internal diagnostics while the display
18    /// message remains safe to send to API clients.
19    #[error("failed to persist response")]
20    Persistence(#[source] Box<ExecutorError>),
21
22    /// A persisted conversation changed after its history was read.
23    ///
24    /// The storage source is retained for internal diagnostics while the
25    /// display message remains safe to send to API clients.
26    #[error("conversation changed while the response was being generated; retry the request")]
27    ConversationLocked {
28        #[source]
29        source: StorageError,
30    },
31
32    /// The LLM backend returned a non-2xx HTTP response.
33    #[error("LLM request failed ({status}): {body}")]
34    LLMRequest {
35        status: StatusCode,
36        body: String,
37        headers: http::HeaderMap,
38    },
39
40    /// The LLM backend could not be reached or timed out before responding.
41    #[error("{message}")]
42    LLMTransport { status: StatusCode, message: &'static str },
43
44    /// A network error occurred reading from the LLM response stream.
45    ///
46    /// The original `reqwest::Error` is preserved as the error source so
47    /// callers can inspect the underlying network failure.
48    #[error("network error: {0}")]
49    NetworkError(
50        #[from]
51        #[source]
52        reqwest::Error,
53    ),
54
55    /// JSON deserialisation failed.
56    ///
57    /// The original `serde_json::Error` is preserved as the error source so
58    /// callers can inspect the exact parse failure location and kind.
59    #[error("json error: {0}")]
60    JsonError(
61        #[from]
62        #[source]
63        serde_json::Error,
64    ),
65
66    /// A general stream processing error with a human-readable message.
67    ///
68    /// Used for non-network stream failures (e.g. worker thread panic).
69    #[error("stream error: {0}")]
70    StreamError(String),
71
72    /// A validation error on the request payload with a human-readable message.
73    ///
74    /// Used when required fields are missing or structurally invalid.
75    #[error("parse error: {0}")]
76    ParseError(String),
77
78    #[error("{entity} not found: {id}")]
79    NotFound { entity: String, id: String },
80
81    #[error("invalid request: {0}")]
82    InvalidRequest(String),
83
84    #[error("compaction summarization failed with status '{status}': {details}")]
85    CompactionFailed { status: String, details: String },
86
87    #[error("tool error: {0}")]
88    Tool(#[from] ToolError),
89}
90
91impl ExecutorError {
92    fn client_visible_error(&self) -> &Self {
93        match self {
94            Self::Persistence(source) if source.contains_conversation_locked() => source.client_visible_error(),
95            _ => self,
96        }
97    }
98
99    fn contains_conversation_locked(&self) -> bool {
100        match self {
101            Self::ConversationLocked { .. } => true,
102            Self::Persistence(source) => source.contains_conversation_locked(),
103            _ => false,
104        }
105    }
106
107    /// HTTP status code that best represents this error to an API caller.
108    #[must_use]
109    pub fn http_status(&self) -> StatusCode {
110        match self.client_visible_error() {
111            Self::Storage(e) if e.is_not_found() => StatusCode::NOT_FOUND,
112            Self::LLMRequest { status, .. } | Self::LLMTransport { status, .. } => *status,
113            Self::ConversationLocked { .. }
114            | Self::Tool(ToolError::Config(_))
115            | Self::InvalidRequest(_)
116            | Self::JsonError(_) => StatusCode::BAD_REQUEST,
117            Self::Tool(ToolError::Execution(_)) | Self::CompactionFailed { .. } => StatusCode::BAD_GATEWAY,
118            Self::ParseError(_) => StatusCode::UNPROCESSABLE_ENTITY,
119            _ => StatusCode::INTERNAL_SERVER_ERROR,
120        }
121    }
122
123    /// Machine-readable error type for the API error envelope.
124    #[must_use]
125    pub fn error_type(&self) -> &'static str {
126        match self.client_visible_error() {
127            Self::ConversationLocked { .. }
128            | Self::Tool(ToolError::Config(_))
129            | Self::InvalidRequest(_)
130            | Self::ParseError(_)
131            | Self::JsonError(_) => "invalid_request_error",
132            Self::Storage(e) if e.is_not_found() => "not_found",
133            Self::LLMRequest { .. } | Self::LLMTransport { .. } | Self::CompactionFailed { .. } => "upstream_error",
134            Self::Tool(ToolError::Execution(_)) => "tool_error",
135            _ => "server_error",
136        }
137    }
138
139    /// Short machine-readable error code for the API error envelope.
140    #[must_use]
141    pub fn error_code(&self) -> &'static str {
142        match self.client_visible_error() {
143            Self::ConversationLocked { .. } => "conversation_locked",
144            other => other.error_type(),
145        }
146    }
147
148    /// Request parameter associated with the API error, when applicable.
149    #[must_use]
150    pub fn error_param(&self) -> Option<&'static str> {
151        matches!(self.client_visible_error(), Self::ConversationLocked { .. }).then_some("conversation")
152    }
153
154    /// Client-safe message for the API error envelope.
155    #[must_use]
156    pub fn error_message(&self) -> String {
157        self.client_visible_error().to_string()
158    }
159
160    /// Serialise the error into the HTTP response body bytes.
161    ///
162    /// `LLMRequest` bodies are forwarded verbatim; all other variants are
163    /// wrapped in the standard `{"error": {"message", "type", "code"}}` envelope.
164    #[must_use]
165    pub fn into_response_body(self) -> Vec<u8> {
166        match self {
167            Self::LLMRequest { body, .. } => body.into_bytes(),
168            other => {
169                let error_type = other.error_type();
170                let code = other.error_code();
171                let mut error = serde_json::Map::new();
172                error.insert("message".to_owned(), serde_json::json!(other.error_message()));
173                error.insert("type".to_owned(), serde_json::json!(error_type));
174                error.insert("code".to_owned(), serde_json::json!(code));
175                if let Some(param) = other.error_param() {
176                    error.insert("param".to_owned(), serde_json::json!(param));
177                }
178                serialize_to_vec_or_default(&serde_json::json!({ "error": error }))
179            }
180        }
181    }
182}
183
184pub type ExecutorResult<T> = Result<T, ExecutorError>;
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_executor_error_display() {
192        let err = ExecutorError::InvalidRequest("test message".into());
193        assert!(err.to_string().contains("invalid request"));
194        assert!(err.to_string().contains("test message"));
195    }
196
197    #[test]
198    fn test_executor_error_stream() {
199        let err = ExecutorError::StreamError("connection lost".into());
200        assert!(err.to_string().contains("stream error"));
201    }
202
203    #[test]
204    fn test_executor_error_not_found() {
205        let err = ExecutorError::NotFound {
206            entity: "Conversation".into(),
207            id: "conv_123".into(),
208        };
209        assert!(err.to_string().contains("Conversation"));
210        assert!(err.to_string().contains("conv_123"));
211    }
212
213    #[test]
214    fn test_executor_error_from_storage() {
215        let storage_err = StorageError::NotConfigured;
216        let exec_err = ExecutorError::from(storage_err);
217        assert!(exec_err.to_string().contains("storage error"));
218    }
219
220    #[test]
221    fn test_executor_error_json_preserves_source() {
222        use std::error::Error;
223        let json_err: serde_json::Error = serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
224        let exec_err = ExecutorError::from(json_err);
225        assert!(exec_err.source().is_some(), "source should be chained");
226        assert!(exec_err.to_string().contains("json error"));
227    }
228
229    #[test]
230    fn conversation_locked_response_preserves_conflict_through_persistence() {
231        use std::error::Error;
232
233        let error = ExecutorError::Persistence(Box::new(ExecutorError::ConversationLocked {
234            source: StorageError::ConversationConflict {
235                conversation_id: "conv_internal".to_owned(),
236            },
237        }));
238
239        let conversation_locked = error.source().expect("persistence source must be retained");
240        let conflict = conversation_locked
241            .source()
242            .expect("conversation conflict source must be retained");
243        assert!(matches!(
244            conflict.downcast_ref::<StorageError>(),
245            Some(StorageError::ConversationConflict { conversation_id })
246                if conversation_id == "conv_internal"
247        ));
248
249        assert_eq!(error.http_status(), StatusCode::BAD_REQUEST);
250        assert_eq!(
251            serde_json::from_slice::<serde_json::Value>(&error.into_response_body())
252                .expect("valid error response JSON"),
253            serde_json::json!({
254                "error": {
255                    "message": "conversation changed while the response was being generated; retry the request",
256                    "type": "invalid_request_error",
257                    "code": "conversation_locked",
258                    "param": "conversation"
259                }
260            })
261        );
262    }
263
264    #[test]
265    fn non_conflict_response_omits_param() {
266        let body = ExecutorError::InvalidRequest("invalid input".to_owned()).into_response_body();
267        let value: serde_json::Value = serde_json::from_slice(&body).expect("valid error response JSON");
268
269        assert!(!value["error"].as_object().expect("error object").contains_key("param"));
270    }
271}