agentic_core/executor/
error.rs1use 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 #[error("storage error: {0}")]
13 Storage(#[from] StorageError),
14
15 #[error("failed to persist response")]
20 Persistence(#[source] Box<ExecutorError>),
21
22 #[error("conversation changed while the response was being generated; retry the request")]
27 ConversationLocked {
28 #[source]
29 source: StorageError,
30 },
31
32 #[error("LLM request failed ({status}): {body}")]
34 LLMRequest {
35 status: StatusCode,
36 body: String,
37 headers: http::HeaderMap,
38 },
39
40 #[error("{message}")]
42 LLMTransport { status: StatusCode, message: &'static str },
43
44 #[error("network error: {0}")]
49 NetworkError(
50 #[from]
51 #[source]
52 reqwest::Error,
53 ),
54
55 #[error("json error: {0}")]
60 JsonError(
61 #[from]
62 #[source]
63 serde_json::Error,
64 ),
65
66 #[error("stream error: {0}")]
70 StreamError(String),
71
72 #[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 #[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 #[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 #[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 #[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 #[must_use]
156 pub fn error_message(&self) -> String {
157 self.client_visible_error().to_string()
158 }
159
160 #[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}