Skip to main content

headless_lms_chatbot/
chatbot_error.rs

1/*!
2Contains error and result types for all the chatbot functions.
3*/
4
5use std::fmt::Display;
6use std::panic::Location;
7
8use backtrace::Backtrace;
9use headless_lms_models::ModelError;
10use headless_lms_utils::error::util_error::UtilError;
11use tracing_error::SpanTrace;
12
13use headless_lms_base::error::backend_error::BackendError;
14
15use crate::azure_chatbot::ResponseError as AzureResponseError;
16use crate::search_filter::SearchFilterError;
17
18/**
19Used as the result types for all of chatbot.
20*/
21pub type ChatbotResult<T> = Result<T, ChatbotError>;
22
23/// The type of [ChatbotError] that occured.
24#[derive(Debug, PartialEq, Eq)]
25pub enum ChatbotErrorType {
26    InvalidMessageShape,
27    InvalidToolName,
28    InvalidToolArguments,
29    ToolUseError,
30    ChatbotModelError,
31    ChatbotMessageSuggestError,
32    UrlParse,
33    TokioIo,
34    SerdeJson,
35    SqlxError,
36    ReqwestError,
37    Other,
38    DeserializationError,
39    AzureAISearchFilterError,
40    StreamingError,
41    ContentCleaning,
42    AzureRequestBuildError,
43    FailedAzureResponse,
44    SisuDescriptionError,
45}
46
47/**
48Error type used in [ChatbotError], which is used for errors related to chatbot functionality.
49
50All the information in the error is meant to be seen by the user. The type of error is determined by the [ChatbotErrorType] enum, which is stored inside this struct.
51
52## Examples
53
54### Usage without source error
55
56```no_run
57# use headless_lms_chatbot::prelude::*;
58# fn random_function() -> ChatbotResult<()> {
59#    let erroneous_condition = 1 == 1;
60if erroneous_condition {
61    return Err(ChatbotError::new(
62        ChatbotErrorType::Other,
63        "File not found".to_string(),
64        None,
65    ));
66}
67# Ok(())
68# }
69```
70
71### Usage with a source error
72
73Used when calling a function that returns an error that cannot be automatically converted to an ChatbotError. (See `impl From<X>` implementations on this struct.)
74
75```no_run
76# use headless_lms_chatbot::prelude::*;
77# fn some_function_returning_an_error() -> ChatbotResult<()> {
78#    return Err(ChatbotError::new(
79#        ChatbotErrorType::Other,
80#        "File not found".to_string(),
81#        None,
82#    ));
83# }
84#
85# fn random_function() -> ChatbotResult<()> {
86#    let erroneous_condition = 1 == 1;
87some_function_returning_an_error().map_err(|original_error| {
88    ChatbotError::new(
89        ChatbotErrorType::Other,
90        "Library x failed to do y".to_string(),
91        Some(original_error.into()),
92    )
93})?;
94# Ok(())
95# }
96```
97*/
98pub struct ChatbotError {
99    error_type: <ChatbotError as BackendError>::ErrorType,
100    message: String,
101    /// Original error that caused this error.
102    source: Option<anyhow::Error>,
103    /// A trace of tokio tracing spans, generated automatically when the error is generated.
104    span_trace: Box<SpanTrace>,
105    /// Stack trace, generated automatically when the error is created.
106    backtrace: Box<Backtrace>,
107    /// Source location where the error was raised.
108    location: Option<&'static Location<'static>>,
109    azure_source: Option<Box<AzureResponseError>>,
110}
111
112impl std::error::Error for ChatbotError {
113    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
114        self.source
115            .as_deref()
116            .map(|e| e as &(dyn std::error::Error + 'static))
117    }
118
119    fn cause(&self) -> Option<&dyn std::error::Error> {
120        self.source()
121    }
122}
123
124// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
125headless_lms_base::impl_clean_debug!(ChatbotError, [ChatbotError, ModelError, UtilError]);
126
127impl Display for ChatbotError {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "ChatbotError {:?} {:?}", self.error_type, self.message)
130    }
131}
132
133impl BackendError for ChatbotError {
134    type ErrorType = ChatbotErrorType;
135
136    fn backtrace(&self) -> Option<&Backtrace> {
137        Some(&self.backtrace)
138    }
139
140    fn error_type(&self) -> &Self::ErrorType {
141        &self.error_type
142    }
143
144    fn message(&self) -> &str {
145        &self.message
146    }
147
148    fn span_trace(&self) -> &SpanTrace {
149        &self.span_trace
150    }
151
152    fn location(&self) -> Option<&'static Location<'static>> {
153        self.location
154    }
155
156    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
157        error_type: Self::ErrorType,
158        message: M,
159        source_error: S,
160        backtrace: Backtrace,
161        span_trace: SpanTrace,
162        location: Option<&'static Location<'static>>,
163    ) -> Self {
164        Self {
165            error_type,
166            message: message.into(),
167            source: source_error.into(),
168            span_trace: Box::new(span_trace),
169            backtrace: Box::new(backtrace),
170            location,
171            azure_source: None,
172        }
173    }
174}
175
176impl ChatbotError {
177    pub fn azure_source(&self) -> Option<AzureResponseError> {
178        self.azure_source.as_deref().cloned()
179    }
180
181    pub fn add_azure_source(&mut self, err: AzureResponseError) {
182        self.azure_source = Some(Box::new(err));
183    }
184}
185
186impl From<url::ParseError> for ChatbotError {
187    fn from(source: url::ParseError) -> Self {
188        Self::new(
189            ChatbotErrorType::UrlParse,
190            source.to_string(),
191            Some(source.into()),
192        )
193    }
194}
195
196impl From<tokio::io::Error> for ChatbotError {
197    fn from(source: tokio::io::Error) -> Self {
198        Self::new(
199            ChatbotErrorType::TokioIo,
200            source.to_string(),
201            Some(source.into()),
202        )
203    }
204}
205
206impl From<serde_json::Error> for ChatbotError {
207    fn from(source: serde_json::Error) -> Self {
208        Self::new(
209            ChatbotErrorType::SerdeJson,
210            source.to_string(),
211            Some(source.into()),
212        )
213    }
214}
215
216impl From<sqlx::Error> for ChatbotError {
217    fn from(err: sqlx::Error) -> ChatbotError {
218        Self::new(
219            ChatbotErrorType::SqlxError,
220            err.to_string(),
221            Some(err.into()),
222        )
223    }
224}
225
226impl From<reqwest::Error> for ChatbotError {
227    fn from(err: reqwest::Error) -> ChatbotError {
228        Self::new(
229            ChatbotErrorType::ReqwestError,
230            err.to_string(),
231            Some(err.into()),
232        )
233    }
234}
235
236impl From<anyhow::Error> for ChatbotError {
237    fn from(err: anyhow::Error) -> ChatbotError {
238        Self::new(ChatbotErrorType::Other, err.to_string(), Some(err))
239    }
240}
241
242impl From<ModelError> for ChatbotError {
243    fn from(err: ModelError) -> ChatbotError {
244        Self::new(
245            ChatbotErrorType::ChatbotModelError,
246            err.to_string(),
247            Some(err.into()),
248        )
249    }
250}
251
252impl From<SearchFilterError> for ChatbotError {
253    fn from(err: SearchFilterError) -> ChatbotError {
254        Self::new(
255            ChatbotErrorType::AzureAISearchFilterError,
256            "Couldn't create search filter for AI search: ".to_string() + &err.to_string(),
257            Some(err.into()),
258        )
259    }
260}
261
262// Generate error creation macros for ChatbotError
263headless_lms_utils::define_err_macro!(
264    chatbot_err,
265    ChatbotError,
266    ChatbotErrorType,
267    ChatbotErrorType,
268    "Create a ChatbotError with less boilerplate."
269);
270
271/// Helper function for `.map_err()` chains to wrap any error as ChatbotError.
272///
273/// This function creates a closure that converts any error into a `ChatbotError`
274/// with the specified error type and message, including the original error as the source.
275///
276/// # Examples
277///
278/// ```ignore
279/// // Instead of:
280/// .map_err(|e| ChatbotError::new(ChatbotErrorType::Other, e.to_string(), Some(e.into())))?
281///
282/// // You can write:
283/// .map_err(as_chatbot_error(ChatbotErrorType::Other, "Failed to process".to_string()))?
284/// ```
285pub fn as_chatbot_error<E>(
286    error_type: ChatbotErrorType,
287    message: impl Into<String>,
288) -> impl FnOnce(E) -> ChatbotError
289where
290    E: Into<anyhow::Error>,
291{
292    let msg = message.into();
293    move |e| ChatbotError::new(error_type, msg, Some(e.into()))
294}
295
296/// Helper function for `.ok_or_else()` to create ChatbotError on None.
297///
298/// This function creates a closure that generates a `ChatbotError` with the
299/// specified error type and message when called.
300///
301/// # Examples
302///
303/// ```ignore
304/// // Instead of:
305/// .ok_or_else(|| ChatbotError::new(ChatbotErrorType::Other, "Item not found".to_string(), None))
306///
307/// // You can write:
308/// .ok_or_else(missing_chatbot_error(ChatbotErrorType::Other, "Item not found".to_string()))
309/// ```
310pub fn missing_chatbot_error(
311    error_type: ChatbotErrorType,
312    message: impl Into<String>,
313) -> impl FnOnce() -> ChatbotError {
314    let msg = message.into();
315    move || ChatbotError::new(error_type, msg, None)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_chatbot_err_macro_without_source() {
324        let err = chatbot_err!(Other, "Test error message".to_string());
325        assert_eq!(err.message(), "Test error message");
326        assert!(matches!(err.error_type(), ChatbotErrorType::Other));
327    }
328
329    #[test]
330    fn test_chatbot_err_macro_with_source() {
331        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
332        let err = chatbot_err!(TokioIo, "Wrapped error".to_string(), source_err);
333        assert_eq!(err.message(), "Wrapped error");
334    }
335
336    #[test]
337    fn test_as_chatbot_error_helper() {
338        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
339            std::io::ErrorKind::NotFound,
340            "test error",
341        ));
342        let chatbot_result = result.map_err(as_chatbot_error(
343            ChatbotErrorType::Other,
344            "Failed to process".to_string(),
345        ));
346
347        assert!(chatbot_result.is_err());
348        let err = chatbot_result.unwrap_err();
349        assert_eq!(err.message(), "Failed to process");
350        assert!(matches!(err.error_type(), ChatbotErrorType::Other));
351    }
352
353    #[test]
354    fn test_missing_chatbot_error_helper() {
355        let option: Option<String> = None;
356        let result = option.ok_or_else(missing_chatbot_error(
357            ChatbotErrorType::InvalidMessageShape,
358            "Message not found".to_string(),
359        ));
360
361        assert!(result.is_err());
362        let err = result.unwrap_err();
363        assert_eq!(err.message(), "Message not found");
364        assert!(matches!(
365            err.error_type(),
366            ChatbotErrorType::InvalidMessageShape
367        ));
368    }
369
370    #[test]
371    fn test_chatbot_err_with_format() {
372        let tool_name = "test_tool";
373        let err = chatbot_err!(InvalidToolName, format!("Unknown tool: {}", tool_name));
374        assert_eq!(err.message(), "Unknown tool: test_tool");
375    }
376
377    /// A three-crate chain (ChatbotError, ModelError, UtilError) renders every link as
378    /// its own node, checking the resolver reaches transitively-wrapped errors.
379    #[test]
380    fn debug_renders_deep_cross_crate_chain() {
381        use headless_lms_utils::error::util_error::UtilErrorType;
382        let util_error = UtilError::new(UtilErrorType::Other, "disk on fire".to_string(), None);
383        let model_error = ModelError::from(util_error);
384        let chatbot_error = ChatbotError::from(model_error);
385
386        let debug = format!("{chatbot_error:?}");
387        assert!(
388            debug.contains("ChatbotError · ChatbotModelError"),
389            "got: {debug}"
390        );
391        assert!(debug.contains("caused by:"), "got: {debug}");
392        assert!(debug.contains("1. ModelError · Util"), "got: {debug}");
393        assert!(
394            debug.contains("2. UtilError · Other: disk on fire"),
395            "got: {debug}"
396        );
397        assert!(!debug.contains("(external)"), "got: {debug}");
398    }
399
400    /// A non-`BackendError` cause is rendered as a message-only `(external)` leaf.
401    #[test]
402    fn debug_tags_external_cause() {
403        let io_error = std::io::Error::other("connection reset");
404        let chatbot_error = chatbot_err!(TokioIo, "request failed".to_string(), io_error);
405
406        let debug = format!("{chatbot_error:?}");
407        assert!(
408            debug.contains("ChatbotError · TokioIo: request failed"),
409            "got: {debug}"
410        );
411        assert!(
412            debug.contains("connection reset  (external)"),
413            "got: {debug}"
414        );
415    }
416}