use http::StatusCode;
use thiserror::Error;
use crate::StorageError;
use crate::tool::ToolError;
use crate::utils::common::serialize_to_vec_or_default;
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum ExecutorError {
#[error("storage error: {0}")]
Storage(#[from] StorageError),
#[error("failed to persist response")]
Persistence(#[source] Box<ExecutorError>),
#[error("conversation changed while the response was being generated; retry the request")]
ConversationLocked {
#[source]
source: StorageError,
},
#[error("LLM request failed ({status}): {body}")]
LLMRequest {
status: StatusCode,
body: String,
headers: http::HeaderMap,
},
#[error("{message}")]
LLMTransport { status: StatusCode, message: &'static str },
#[error("network error: {0}")]
NetworkError(
#[from]
#[source]
reqwest::Error,
),
#[error("json error: {0}")]
JsonError(
#[from]
#[source]
serde_json::Error,
),
#[error("stream error: {0}")]
StreamError(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("{entity} not found: {id}")]
NotFound { entity: String, id: String },
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("compaction summarization failed with status '{status}': {details}")]
CompactionFailed { status: String, details: String },
#[error("tool error: {0}")]
Tool(#[from] ToolError),
}
impl ExecutorError {
fn client_visible_error(&self) -> &Self {
match self {
Self::Persistence(source) if source.contains_conversation_locked() => source.client_visible_error(),
_ => self,
}
}
fn contains_conversation_locked(&self) -> bool {
match self {
Self::ConversationLocked { .. } => true,
Self::Persistence(source) => source.contains_conversation_locked(),
_ => false,
}
}
#[must_use]
pub fn http_status(&self) -> StatusCode {
match self.client_visible_error() {
Self::Storage(e) if e.is_not_found() => StatusCode::NOT_FOUND,
Self::LLMRequest { status, .. } | Self::LLMTransport { status, .. } => *status,
Self::ConversationLocked { .. }
| Self::Tool(ToolError::Config(_))
| Self::InvalidRequest(_)
| Self::JsonError(_) => StatusCode::BAD_REQUEST,
Self::Tool(ToolError::Execution(_)) | Self::CompactionFailed { .. } => StatusCode::BAD_GATEWAY,
Self::ParseError(_) => StatusCode::UNPROCESSABLE_ENTITY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[must_use]
pub fn error_type(&self) -> &'static str {
match self.client_visible_error() {
Self::ConversationLocked { .. }
| Self::Tool(ToolError::Config(_))
| Self::InvalidRequest(_)
| Self::ParseError(_)
| Self::JsonError(_) => "invalid_request_error",
Self::Storage(e) if e.is_not_found() => "not_found",
Self::LLMRequest { .. } | Self::LLMTransport { .. } | Self::CompactionFailed { .. } => "upstream_error",
Self::Tool(ToolError::Execution(_)) => "tool_error",
_ => "server_error",
}
}
#[must_use]
pub fn error_code(&self) -> &'static str {
match self.client_visible_error() {
Self::ConversationLocked { .. } => "conversation_locked",
other => other.error_type(),
}
}
#[must_use]
pub fn error_param(&self) -> Option<&'static str> {
matches!(self.client_visible_error(), Self::ConversationLocked { .. }).then_some("conversation")
}
#[must_use]
pub fn error_message(&self) -> String {
self.client_visible_error().to_string()
}
#[must_use]
pub fn into_response_body(self) -> Vec<u8> {
match self {
Self::LLMRequest { body, .. } => body.into_bytes(),
other => {
let error_type = other.error_type();
let code = other.error_code();
let mut error = serde_json::Map::new();
error.insert("message".to_owned(), serde_json::json!(other.error_message()));
error.insert("type".to_owned(), serde_json::json!(error_type));
error.insert("code".to_owned(), serde_json::json!(code));
if let Some(param) = other.error_param() {
error.insert("param".to_owned(), serde_json::json!(param));
}
serialize_to_vec_or_default(&serde_json::json!({ "error": error }))
}
}
}
}
pub type ExecutorResult<T> = Result<T, ExecutorError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_executor_error_display() {
let err = ExecutorError::InvalidRequest("test message".into());
assert!(err.to_string().contains("invalid request"));
assert!(err.to_string().contains("test message"));
}
#[test]
fn test_executor_error_stream() {
let err = ExecutorError::StreamError("connection lost".into());
assert!(err.to_string().contains("stream error"));
}
#[test]
fn test_executor_error_not_found() {
let err = ExecutorError::NotFound {
entity: "Conversation".into(),
id: "conv_123".into(),
};
assert!(err.to_string().contains("Conversation"));
assert!(err.to_string().contains("conv_123"));
}
#[test]
fn test_executor_error_from_storage() {
let storage_err = StorageError::NotConfigured;
let exec_err = ExecutorError::from(storage_err);
assert!(exec_err.to_string().contains("storage error"));
}
#[test]
fn test_executor_error_json_preserves_source() {
use std::error::Error;
let json_err: serde_json::Error = serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
let exec_err = ExecutorError::from(json_err);
assert!(exec_err.source().is_some(), "source should be chained");
assert!(exec_err.to_string().contains("json error"));
}
#[test]
fn conversation_locked_response_preserves_conflict_through_persistence() {
use std::error::Error;
let error = ExecutorError::Persistence(Box::new(ExecutorError::ConversationLocked {
source: StorageError::ConversationConflict {
conversation_id: "conv_internal".to_owned(),
},
}));
let conversation_locked = error.source().expect("persistence source must be retained");
let conflict = conversation_locked
.source()
.expect("conversation conflict source must be retained");
assert!(matches!(
conflict.downcast_ref::<StorageError>(),
Some(StorageError::ConversationConflict { conversation_id })
if conversation_id == "conv_internal"
));
assert_eq!(error.http_status(), StatusCode::BAD_REQUEST);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&error.into_response_body())
.expect("valid error response JSON"),
serde_json::json!({
"error": {
"message": "conversation changed while the response was being generated; retry the request",
"type": "invalid_request_error",
"code": "conversation_locked",
"param": "conversation"
}
})
);
}
#[test]
fn non_conflict_response_omits_param() {
let body = ExecutorError::InvalidRequest("invalid input".to_owned()).into_response_body();
let value: serde_json::Value = serde_json::from_slice(&body).expect("valid error response JSON");
assert!(!value["error"].as_object().expect("error object").contains_key("param"));
}
}