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 { status: StatusCode, body: String },
35
36 #[error("network error: {0}")]
41 NetworkError(
42 #[from]
43 #[source]
44 reqwest::Error,
45 ),
46
47 #[error("json error: {0}")]
52 JsonError(
53 #[from]
54 #[source]
55 serde_json::Error,
56 ),
57
58 #[error("stream error: {0}")]
62 StreamError(String),
63
64 #[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 #[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 #[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 #[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 #[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 #[must_use]
148 pub fn error_message(&self) -> String {
149 self.client_visible_error().to_string()
150 }
151
152 #[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}