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_authorization::error::AuthorizationError;
10use headless_lms_models::ModelError;
11use headless_lms_utils::error::util_error::UtilError;
12use tracing_error::SpanTrace;
13
14use headless_lms_base::error::backend_error::BackendError;
15
16use crate::azure_chatbot::azure::protocol::ResponseError as AzureResponseError;
17use crate::search_filter::SearchFilterError;
18
19/**
20Used as the result types for all of chatbot.
21*/
22pub type ChatbotResult<T> = Result<T, ChatbotError>;
23
24/// The type of [ChatbotError] that occured.
25#[derive(Debug, PartialEq, Eq)]
26pub enum ChatbotErrorType {
27    InvalidMessageShape,
28    InvalidToolName,
29    InvalidToolArguments,
30    /// A client answered a tool call the conversation has no room for. The only chatbot error
31    /// type the client is at fault for, so the only one that leaves the server as a 4xx.
32    InvalidToolAnswer,
33    ToolUseError,
34    ChatbotModelError,
35    ChatbotMessageSuggestError,
36    UrlParse,
37    TokioIo,
38    SerdeJson,
39    SqlxError,
40    ReqwestError,
41    Other,
42    DeserializationError,
43    AzureAISearchFilterError,
44    /// Azure reported a failure on the response itself (a `response.error` or `.failed` object).
45    UpstreamReportedError,
46    /// Azure marked the response incomplete (e.g. truncated by a token or content-filter limit).
47    ResponseIncomplete,
48    /// The stream (or the request that was supposed to open one) ended before the turn reached
49    /// a point where it could be resolved one way or the other.
50    StreamEndedEarly,
51    /// A stream line didn't have the shape a given parsing state expected, though the byte-level
52    /// structure it arrived in was still well-formed.
53    UnexpectedProtocolShape,
54    /// One of our own assumptions about the protocol was violated in a way that indicates a bug
55    /// on our side rather than something Azure sent, e.g. a response id we always expect to be
56    /// set by that point in the stream.
57    StreamInvariantViolation,
58    ContentCleaning,
59    AzureRequestBuildError,
60    FailedAzureResponse,
61    SisuDescriptionError,
62    ChatbotUtilError,
63}
64
65impl ChatbotErrorType {
66    /// Whether a stream-level failure of this kind should drop the connection rather than
67    /// report an Error event to the client and let the turn continue. Exhaustive so that adding
68    /// a variant forces a decision here instead of silently defaulting to "show and carry on".
69    pub fn should_terminate_stream(&self) -> bool {
70        match self {
71            ChatbotErrorType::SerdeJson
72            | ChatbotErrorType::DeserializationError
73            | ChatbotErrorType::SqlxError
74            | ChatbotErrorType::ReqwestError
75            | ChatbotErrorType::UrlParse => true,
76            ChatbotErrorType::InvalidMessageShape
77            | ChatbotErrorType::InvalidToolName
78            | ChatbotErrorType::InvalidToolArguments
79            | ChatbotErrorType::InvalidToolAnswer
80            | ChatbotErrorType::ToolUseError
81            | ChatbotErrorType::ChatbotModelError
82            | ChatbotErrorType::ChatbotMessageSuggestError
83            | ChatbotErrorType::TokioIo
84            | ChatbotErrorType::Other
85            | ChatbotErrorType::AzureAISearchFilterError
86            | ChatbotErrorType::UpstreamReportedError
87            | ChatbotErrorType::ResponseIncomplete
88            | ChatbotErrorType::StreamEndedEarly
89            | ChatbotErrorType::UnexpectedProtocolShape
90            | ChatbotErrorType::StreamInvariantViolation
91            | ChatbotErrorType::ContentCleaning
92            | ChatbotErrorType::AzureRequestBuildError
93            | ChatbotErrorType::FailedAzureResponse
94            | ChatbotErrorType::SisuDescriptionError
95            | ChatbotErrorType::ChatbotUtilError => false,
96        }
97    }
98}
99
100/**
101Error type used in [ChatbotError], which is used for errors related to chatbot functionality.
102
103All 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.
104
105## Examples
106
107### Usage without source error
108
109```no_run
110# use headless_lms_chatbot::prelude::*;
111# fn random_function() -> ChatbotResult<()> {
112#    let erroneous_condition = 1 == 1;
113if erroneous_condition {
114    return Err(ChatbotError::new(
115        ChatbotErrorType::Other,
116        "File not found".to_string(),
117        None,
118    ));
119}
120# Ok(())
121# }
122```
123
124### Usage with a source error
125
126Used when calling a function that returns an error that cannot be automatically converted to an ChatbotError. (See `impl From<X>` implementations on this struct.)
127
128```no_run
129# use headless_lms_chatbot::prelude::*;
130# fn some_function_returning_an_error() -> ChatbotResult<()> {
131#    return Err(ChatbotError::new(
132#        ChatbotErrorType::Other,
133#        "File not found".to_string(),
134#        None,
135#    ));
136# }
137#
138# fn random_function() -> ChatbotResult<()> {
139#    let erroneous_condition = 1 == 1;
140some_function_returning_an_error().map_err(|original_error| {
141    ChatbotError::new(
142        ChatbotErrorType::Other,
143        "Library x failed to do y".to_string(),
144        Some(original_error.into()),
145    )
146})?;
147# Ok(())
148# }
149```
150*/
151pub struct ChatbotError {
152    error_type: <ChatbotError as BackendError>::ErrorType,
153    message: String,
154    /// Original error that caused this error.
155    source: Option<anyhow::Error>,
156    /// A trace of tokio tracing spans, generated automatically when the error is generated.
157    span_trace: Box<SpanTrace>,
158    /// Stack trace, generated automatically when the error is created.
159    backtrace: Box<Backtrace>,
160    /// Source location where the error was raised.
161    location: Option<&'static Location<'static>>,
162}
163
164impl std::error::Error for ChatbotError {
165    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
166        self.source
167            .as_deref()
168            .map(|e| e as &(dyn std::error::Error + 'static))
169    }
170
171    fn cause(&self) -> Option<&dyn std::error::Error> {
172        self.source()
173    }
174}
175
176// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
177headless_lms_base::impl_clean_debug!(ChatbotError, [ChatbotError, ModelError, UtilError]);
178
179impl Display for ChatbotError {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        write!(f, "ChatbotError {:?} {:?}", self.error_type, self.message)
182    }
183}
184
185impl BackendError for ChatbotError {
186    type ErrorType = ChatbotErrorType;
187
188    fn backtrace(&self) -> Option<&Backtrace> {
189        Some(&self.backtrace)
190    }
191
192    fn error_type(&self) -> &Self::ErrorType {
193        &self.error_type
194    }
195
196    fn message(&self) -> &str {
197        &self.message
198    }
199
200    fn span_trace(&self) -> &SpanTrace {
201        &self.span_trace
202    }
203
204    fn location(&self) -> Option<&'static Location<'static>> {
205        self.location
206    }
207
208    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
209        error_type: Self::ErrorType,
210        message: M,
211        source_error: S,
212        backtrace: Backtrace,
213        span_trace: SpanTrace,
214        location: Option<&'static Location<'static>>,
215    ) -> Self {
216        Self {
217            error_type,
218            message: message.into(),
219            source: source_error.into(),
220            span_trace: Box::new(span_trace),
221            backtrace: Box::new(backtrace),
222            location,
223        }
224    }
225}
226
227impl ChatbotError {
228    /// The Azure error this error's source chain holds, if any.
229    pub fn azure_source(&self) -> Option<AzureResponseError> {
230        self.source
231            .as_ref()
232            .and_then(|source| source.downcast_ref::<AzureResponseError>())
233            .cloned()
234    }
235
236    /// Attaches Azure's own error object as this error's source, so it joins the ordinary cause
237    /// chain instead of a side channel invisible to the developer-facing error log. Only meant
238    /// for a freshly constructed error with no source yet; overwrites one if there already is.
239    pub fn add_azure_source(&mut self, err: AzureResponseError) {
240        self.source = Some(err.into());
241    }
242
243    /// Unwraps a [ChatbotErrorType::ChatbotModelError] into the [ModelError] it wraps, so the
244    /// caller can apply its own `ModelError` mapping instead of collapsing every data-loading
245    /// failure into one status code. Any other error is handed back unchanged.
246    pub fn into_model_error(mut self) -> Result<ModelError, Self> {
247        if !matches!(self.error_type, ChatbotErrorType::ChatbotModelError) {
248            return Err(self);
249        }
250        match self.source.take().map(|source| source.downcast()) {
251            Some(Ok(model_error)) => Ok(model_error),
252            Some(Err(source)) => {
253                self.source = Some(source);
254                Err(self)
255            }
256            None => Err(self),
257        }
258    }
259}
260
261impl std::error::Error for AzureResponseError {}
262
263impl From<url::ParseError> for ChatbotError {
264    fn from(source: url::ParseError) -> Self {
265        Self::new(
266            ChatbotErrorType::UrlParse,
267            source.to_string(),
268            Some(source.into()),
269        )
270    }
271}
272
273impl From<tokio::io::Error> for ChatbotError {
274    fn from(source: tokio::io::Error) -> Self {
275        Self::new(
276            ChatbotErrorType::TokioIo,
277            source.to_string(),
278            Some(source.into()),
279        )
280    }
281}
282
283impl From<serde_json::Error> for ChatbotError {
284    fn from(source: serde_json::Error) -> Self {
285        Self::new(
286            ChatbotErrorType::SerdeJson,
287            source.to_string(),
288            Some(source.into()),
289        )
290    }
291}
292
293impl From<sqlx::Error> for ChatbotError {
294    fn from(err: sqlx::Error) -> ChatbotError {
295        Self::new(
296            ChatbotErrorType::SqlxError,
297            err.to_string(),
298            Some(err.into()),
299        )
300    }
301}
302
303impl From<reqwest::Error> for ChatbotError {
304    fn from(err: reqwest::Error) -> ChatbotError {
305        Self::new(
306            ChatbotErrorType::ReqwestError,
307            err.to_string(),
308            Some(err.into()),
309        )
310    }
311}
312
313impl From<anyhow::Error> for ChatbotError {
314    fn from(err: anyhow::Error) -> ChatbotError {
315        Self::new(ChatbotErrorType::Other, err.to_string(), Some(err))
316    }
317}
318
319impl From<ModelError> for ChatbotError {
320    fn from(err: ModelError) -> ChatbotError {
321        Self::new(
322            ChatbotErrorType::ChatbotModelError,
323            err.to_string(),
324            Some(err.into()),
325        )
326    }
327}
328
329impl From<AuthorizationError> for ChatbotError {
330    fn from(err: AuthorizationError) -> ChatbotError {
331        // A check that failed because the models layer did is mapped like any other ModelError,
332        // so that a missing course does not read as a permission problem.
333        let err = match err.into_model_error() {
334            Ok(model_error) => return model_error.into(),
335            Err(err) => err,
336        };
337        Self::new(ChatbotErrorType::Other, err.to_string(), Some(err.into()))
338    }
339}
340
341impl From<UtilError> for ChatbotError {
342    fn from(err: UtilError) -> ChatbotError {
343        Self::new(
344            ChatbotErrorType::ChatbotUtilError,
345            err.to_string(),
346            Some(err.into()),
347        )
348    }
349}
350
351impl From<SearchFilterError> for ChatbotError {
352    fn from(err: SearchFilterError) -> ChatbotError {
353        Self::new(
354            ChatbotErrorType::AzureAISearchFilterError,
355            "Couldn't create search filter for AI search: ".to_string() + &err.to_string(),
356            Some(err.into()),
357        )
358    }
359}
360
361// Generate error creation macros for ChatbotError
362headless_lms_utils::define_err_macro!(
363    chatbot_err,
364    ChatbotError,
365    ChatbotErrorType,
366    ChatbotErrorType,
367    "Create a ChatbotError with less boilerplate."
368);
369
370/// Helper function for `.map_err()` chains to wrap any error as ChatbotError.
371///
372/// This function creates a closure that converts any error into a `ChatbotError`
373/// with the specified error type and message, including the original error as the source.
374///
375/// # Examples
376///
377/// ```ignore
378/// // Instead of:
379/// .map_err(|e| ChatbotError::new(ChatbotErrorType::Other, e.to_string(), Some(e.into())))?
380///
381/// // You can write:
382/// .map_err(as_chatbot_error(ChatbotErrorType::Other, "Failed to process".to_string()))?
383/// ```
384pub fn as_chatbot_error<E>(
385    error_type: ChatbotErrorType,
386    message: impl Into<String>,
387) -> impl FnOnce(E) -> ChatbotError
388where
389    E: Into<anyhow::Error>,
390{
391    let msg = message.into();
392    move |e| ChatbotError::new(error_type, msg, Some(e.into()))
393}
394
395/// Helper function for `.ok_or_else()` to create ChatbotError on None.
396///
397/// This function creates a closure that generates a `ChatbotError` with the
398/// specified error type and message when called.
399///
400/// # Examples
401///
402/// ```ignore
403/// // Instead of:
404/// .ok_or_else(|| ChatbotError::new(ChatbotErrorType::Other, "Item not found".to_string(), None))
405///
406/// // You can write:
407/// .ok_or_else(missing_chatbot_error(ChatbotErrorType::Other, "Item not found".to_string()))
408/// ```
409pub fn missing_chatbot_error(
410    error_type: ChatbotErrorType,
411    message: impl Into<String>,
412) -> impl FnOnce() -> ChatbotError {
413    let msg = message.into();
414    move || ChatbotError::new(error_type, msg, None)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_chatbot_err_macro_without_source() {
423        let err = chatbot_err!(Other, "Test error message".to_string());
424        assert_eq!(err.message(), "Test error message");
425        assert!(matches!(err.error_type(), ChatbotErrorType::Other));
426    }
427
428    #[test]
429    fn test_chatbot_err_macro_with_source() {
430        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
431        let err = chatbot_err!(TokioIo, "Wrapped error".to_string(), source_err);
432        assert_eq!(err.message(), "Wrapped error");
433    }
434
435    #[test]
436    fn test_as_chatbot_error_helper() {
437        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
438            std::io::ErrorKind::NotFound,
439            "test error",
440        ));
441        let chatbot_result = result.map_err(as_chatbot_error(
442            ChatbotErrorType::Other,
443            "Failed to process".to_string(),
444        ));
445
446        assert!(chatbot_result.is_err());
447        let err = chatbot_result.unwrap_err();
448        assert_eq!(err.message(), "Failed to process");
449        assert!(matches!(err.error_type(), ChatbotErrorType::Other));
450    }
451
452    #[test]
453    fn test_missing_chatbot_error_helper() {
454        let option: Option<String> = None;
455        let result = option.ok_or_else(missing_chatbot_error(
456            ChatbotErrorType::InvalidMessageShape,
457            "Message not found".to_string(),
458        ));
459
460        assert!(result.is_err());
461        let err = result.unwrap_err();
462        assert_eq!(err.message(), "Message not found");
463        assert!(matches!(
464            err.error_type(),
465            ChatbotErrorType::InvalidMessageShape
466        ));
467    }
468
469    #[test]
470    fn test_chatbot_err_with_format() {
471        let tool_name = "test_tool";
472        let err = chatbot_err!(InvalidToolName, format!("Unknown tool: {}", tool_name));
473        assert_eq!(err.message(), "Unknown tool: test_tool");
474    }
475
476    /// A three-crate chain (ChatbotError, ModelError, UtilError) renders every link as
477    /// its own node, checking the resolver reaches transitively-wrapped errors.
478    #[test]
479    fn debug_renders_deep_cross_crate_chain() {
480        use headless_lms_utils::error::util_error::UtilErrorType;
481        let util_error = UtilError::new(UtilErrorType::Other, "disk on fire".to_string(), None);
482        let model_error = ModelError::from(util_error);
483        let chatbot_error = ChatbotError::from(model_error);
484
485        let debug = format!("{chatbot_error:?}");
486        assert!(
487            debug.contains("ChatbotError · ChatbotModelError"),
488            "got: {debug}"
489        );
490        assert!(debug.contains("caused by:"), "got: {debug}");
491        assert!(debug.contains("1. ModelError · Util"), "got: {debug}");
492        assert!(
493            debug.contains("2. UtilError · Other: disk on fire"),
494            "got: {debug}"
495        );
496        assert!(!debug.contains("(external)"), "got: {debug}");
497    }
498
499    /// A non-`BackendError` cause is rendered as a message-only `(external)` leaf.
500    #[test]
501    fn debug_tags_external_cause() {
502        let io_error = std::io::Error::other("connection reset");
503        let chatbot_error = chatbot_err!(TokioIo, "request failed".to_string(), io_error);
504
505        let debug = format!("{chatbot_error:?}");
506        assert!(
507            debug.contains("ChatbotError · TokioIo: request failed"),
508            "got: {debug}"
509        );
510        assert!(
511            debug.contains("connection reset  (external)"),
512            "got: {debug}"
513        );
514    }
515}