agentic-server-core 0.5.0

Framework-agnostic core library for agentic-api
Documentation
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 {
    /// A storage layer operation failed.
    #[error("storage error: {0}")]
    Storage(#[from] StorageError),

    /// Persistence failed after inference completed.
    ///
    /// The source is retained for internal diagnostics while the display
    /// message remains safe to send to API clients.
    #[error("failed to persist response")]
    Persistence(#[source] Box<ExecutorError>),

    /// A persisted conversation changed after its history was read.
    ///
    /// The storage source is retained for internal diagnostics while the
    /// display message remains safe to send to API clients.
    #[error("conversation changed while the response was being generated; retry the request")]
    ConversationLocked {
        #[source]
        source: StorageError,
    },

    /// The LLM backend returned a non-2xx HTTP response.
    #[error("LLM request failed ({status}): {body}")]
    LLMRequest {
        status: StatusCode,
        body: String,
        headers: http::HeaderMap,
    },

    /// The LLM backend could not be reached or timed out before responding.
    #[error("{message}")]
    LLMTransport { status: StatusCode, message: &'static str },

    /// A network error occurred reading from the LLM response stream.
    ///
    /// The original `reqwest::Error` is preserved as the error source so
    /// callers can inspect the underlying network failure.
    #[error("network error: {0}")]
    NetworkError(
        #[from]
        #[source]
        reqwest::Error,
    ),

    /// JSON deserialisation failed.
    ///
    /// The original `serde_json::Error` is preserved as the error source so
    /// callers can inspect the exact parse failure location and kind.
    #[error("json error: {0}")]
    JsonError(
        #[from]
        #[source]
        serde_json::Error,
    ),

    /// A general stream processing error with a human-readable message.
    ///
    /// Used for non-network stream failures (e.g. worker thread panic).
    #[error("stream error: {0}")]
    StreamError(String),

    /// A validation error on the request payload with a human-readable message.
    ///
    /// Used when required fields are missing or structurally invalid.
    #[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,
        }
    }

    /// HTTP status code that best represents this error to an API caller.
    #[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,
        }
    }

    /// Machine-readable error type for the API error envelope.
    #[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",
        }
    }

    /// Short machine-readable error code for the API error envelope.
    #[must_use]
    pub fn error_code(&self) -> &'static str {
        match self.client_visible_error() {
            Self::ConversationLocked { .. } => "conversation_locked",
            other => other.error_type(),
        }
    }

    /// Request parameter associated with the API error, when applicable.
    #[must_use]
    pub fn error_param(&self) -> Option<&'static str> {
        matches!(self.client_visible_error(), Self::ConversationLocked { .. }).then_some("conversation")
    }

    /// Client-safe message for the API error envelope.
    #[must_use]
    pub fn error_message(&self) -> String {
        self.client_visible_error().to_string()
    }

    /// Serialise the error into the HTTP response body bytes.
    ///
    /// `LLMRequest` bodies are forwarded verbatim; all other variants are
    /// wrapped in the standard `{"error": {"message", "type", "code"}}` envelope.
    #[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"));
    }
}