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