Skip to main content

headless_lms_utils/error/
util_error.rs

1/*!
2Contains error and result types for all the util functions.
3*/
4
5use std::fmt::Display;
6use std::panic::Location;
7
8use backtrace::Backtrace;
9use headless_lms_base::error::backend_error::BackendError;
10use tracing_error::SpanTrace;
11/**
12Used as the result types for all utils.
13
14See also [UtilError] for documentation on how to return errors from models.
15*/
16pub type UtilResult<T> = Result<T, UtilError>;
17
18/// The type of [UtilError] that occured.
19#[derive(Debug)]
20pub enum UtilErrorType {
21    UrlParse,
22    Walkdir,
23    StripPrefix,
24    TokioIo,
25    SerdeJson,
26    CloudStorage,
27    Other,
28    Unavailable,
29    DeserializationError,
30    /// The request to TMC never completed (connection error, timeout): a transport
31    /// failure with no upstream HTTP status.
32    TmcHttpError,
33    /// TMC responded with a non-success HTTP status. Carries the status so callers can
34    /// tell an upstream auth rejection (401/403) from an upstream server error (5xx).
35    TmcHttpStatusError(u16),
36    TmcErrorResponse,
37    EmbeddingRequestBuildError,
38    ReqwestError,
39    SisuClientError(SisuErrorVariant),
40    /// A Suotar call got no batch response; see `services::suotar::SuotarError`.
41    SuotarClientError,
42}
43#[derive(Debug)]
44
45pub enum SisuErrorVariant {
46    GenericSisuError,
47    InvalidCourseCode,
48    SisuResourceNotFound,
49}
50
51/**
52Error type used by all models. Used as the error type in [UtilError], which is used by all the controllers in the application.
53
54All the information in the error is meant to be seen by the user. The type of error is determined by the [UtilErrorType] enum, which is stored inside this struct.
55
56## Examples
57
58### Usage without source error
59
60```no_run
61# use headless_lms_utils::prelude::*;
62# fn random_function() -> UtilResult<()> {
63#    let erroneous_condition = 1 == 1;
64if erroneous_condition {
65    return Err(UtilError::new(
66        UtilErrorType::Other,
67        "File not found".to_string(),
68        None,
69    ));
70}
71# Ok(())
72# }
73```
74
75### Usage with a source error
76
77Used when calling a function that returns an error that cannot be automatically converted to an UtilError. (See `impl From<X>` implementations on this struct.)
78
79```no_run
80# use headless_lms_utils::prelude::*;
81# fn some_function_returning_an_error() -> UtilResult<()> {
82#    return Err(UtilError::new(
83#        UtilErrorType::Other,
84#        "File not found".to_string(),
85#        None,
86#    ));
87# }
88#
89# fn random_function() -> UtilResult<()> {
90#    let erroneous_condition = 1 == 1;
91some_function_returning_an_error().map_err(|original_error| {
92    UtilError::new(
93        UtilErrorType::Other,
94        "Library x failed to do y".to_string(),
95        Some(original_error.into()),
96    )
97})?;
98# Ok(())
99# }
100```
101*/
102pub struct UtilError {
103    error_type: <UtilError as BackendError>::ErrorType,
104    message: String,
105    /// Original error that caused this error.
106    source: Option<anyhow::Error>,
107    /// A trace of tokio tracing spans, generated automatically when the error is generated.
108    span_trace: Box<SpanTrace>,
109    /// Stack trace, generated automatically when the error is created.
110    backtrace: Box<Backtrace>,
111    /// Source location where the error was raised.
112    location: Option<&'static Location<'static>>,
113}
114
115impl std::error::Error for UtilError {
116    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
117        self.source
118            .as_deref()
119            .map(|e| e as &(dyn std::error::Error + 'static))
120    }
121
122    fn cause(&self) -> Option<&dyn std::error::Error> {
123        self.source()
124    }
125}
126
127// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
128headless_lms_base::impl_clean_debug!(UtilError, [UtilError]);
129
130impl Display for UtilError {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "UtilError {:?} {:?}", self.error_type, self.message)
133    }
134}
135
136impl BackendError for UtilError {
137    type ErrorType = UtilErrorType;
138
139    fn backtrace(&self) -> Option<&Backtrace> {
140        Some(&self.backtrace)
141    }
142
143    fn error_type(&self) -> &Self::ErrorType {
144        &self.error_type
145    }
146
147    fn message(&self) -> &str {
148        &self.message
149    }
150
151    fn span_trace(&self) -> &SpanTrace {
152        &self.span_trace
153    }
154
155    fn location(&self) -> Option<&'static Location<'static>> {
156        self.location
157    }
158
159    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
160        error_type: Self::ErrorType,
161        message: M,
162        source_error: S,
163        backtrace: Backtrace,
164        span_trace: SpanTrace,
165        location: Option<&'static Location<'static>>,
166    ) -> Self {
167        Self {
168            error_type,
169            message: message.into(),
170            source: source_error.into(),
171            span_trace: Box::new(span_trace),
172            backtrace: Box::new(backtrace),
173            location,
174        }
175    }
176}
177
178impl From<url::ParseError> for UtilError {
179    fn from(source: url::ParseError) -> Self {
180        UtilError::new(
181            UtilErrorType::UrlParse,
182            source.to_string(),
183            Some(source.into()),
184        )
185    }
186}
187
188impl From<walkdir::Error> for UtilError {
189    fn from(source: walkdir::Error) -> Self {
190        UtilError::new(
191            UtilErrorType::Walkdir,
192            source.to_string(),
193            Some(source.into()),
194        )
195    }
196}
197
198impl From<reqwest::Error> for UtilError {
199    fn from(err: reqwest::Error) -> UtilError {
200        Self::new(
201            UtilErrorType::ReqwestError,
202            err.to_string(),
203            Some(err.into()),
204        )
205    }
206}
207
208impl From<std::path::StripPrefixError> for UtilError {
209    fn from(source: std::path::StripPrefixError) -> Self {
210        UtilError::new(
211            UtilErrorType::StripPrefix,
212            source.to_string(),
213            Some(source.into()),
214        )
215    }
216}
217
218impl From<tokio::io::Error> for UtilError {
219    fn from(source: tokio::io::Error) -> Self {
220        UtilError::new(
221            UtilErrorType::TokioIo,
222            source.to_string(),
223            Some(source.into()),
224        )
225    }
226}
227
228impl From<serde_json::Error> for UtilError {
229    fn from(source: serde_json::Error) -> Self {
230        UtilError::new(
231            UtilErrorType::SerdeJson,
232            source.to_string(),
233            Some(source.into()),
234        )
235    }
236}
237
238impl From<google_cloud_storage::Error> for UtilError {
239    fn from(source: google_cloud_storage::Error) -> Self {
240        UtilError::new(
241            UtilErrorType::CloudStorage,
242            source.to_string(),
243            Some(source.into()),
244        )
245    }
246}
247
248impl From<anyhow::Error> for UtilError {
249    fn from(err: anyhow::Error) -> UtilError {
250        Self::new(UtilErrorType::Other, err.to_string(), Some(err))
251    }
252}
253
254// Generate error creation macros for UtilError
255crate::define_err_macro!(
256    util_err,
257    UtilError,
258    UtilErrorType,
259    UtilErrorType,
260    "Create a UtilError with less boilerplate."
261);
262
263/// Helper function for `.map_err()` chains to wrap any error as UtilError.
264///
265/// This function creates a closure that converts any error into a `UtilError`
266/// with the specified error type and message, including the original error as the source.
267///
268/// # Examples
269///
270/// ```ignore
271/// // Instead of:
272/// .map_err(|e| UtilError::new(UtilErrorType::Other, e.to_string(), Some(e.into())))?
273///
274/// // You can write:
275/// .map_err(as_util_error(UtilErrorType::Other, "Failed to process".to_string()))?
276/// ```
277pub fn as_util_error<E>(
278    error_type: UtilErrorType,
279    message: impl Into<String>,
280) -> impl FnOnce(E) -> UtilError
281where
282    E: Into<anyhow::Error>,
283{
284    let msg = message.into();
285    move |e| UtilError::new(error_type, msg, Some(e.into()))
286}
287
288/// Helper function for `.ok_or_else()` to create UtilError on None.
289///
290/// This function creates a closure that generates a `UtilError` with the
291/// specified error type and message when called.
292///
293/// # Examples
294///
295/// ```ignore
296/// // Instead of:
297/// .ok_or_else(|| UtilError::new(UtilErrorType::Other, "Item not found".to_string(), None))
298///
299/// // You can write:
300/// .ok_or_else(missing_util_error(UtilErrorType::Other, "Item not found".to_string()))
301/// ```
302pub fn missing_util_error(
303    error_type: UtilErrorType,
304    message: impl Into<String>,
305) -> impl FnOnce() -> UtilError {
306    let msg = message.into();
307    move || UtilError::new(error_type, msg, None)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_util_err_macro_without_source() {
316        let err = util_err!(Other, "Test error message".to_string());
317        assert_eq!(err.message(), "Test error message");
318        assert!(matches!(err.error_type(), UtilErrorType::Other));
319    }
320
321    #[test]
322    fn test_util_err_macro_with_source() {
323        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
324        let err = util_err!(TokioIo, "Wrapped error".to_string(), source_err);
325        assert_eq!(err.message(), "Wrapped error");
326    }
327
328    #[test]
329    fn test_as_util_error_helper() {
330        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
331            std::io::ErrorKind::NotFound,
332            "test error",
333        ));
334        let util_result = result.map_err(as_util_error(
335            UtilErrorType::TokioIo,
336            "Failed to read file".to_string(),
337        ));
338
339        assert!(util_result.is_err());
340        let err = util_result.unwrap_err();
341        assert_eq!(err.message(), "Failed to read file");
342        assert!(matches!(err.error_type(), UtilErrorType::TokioIo));
343    }
344
345    #[test]
346    fn test_missing_util_error_helper() {
347        let option: Option<String> = None;
348        let result = option.ok_or_else(missing_util_error(
349            UtilErrorType::Other,
350            "Item not found".to_string(),
351        ));
352
353        assert!(result.is_err());
354        let err = result.unwrap_err();
355        assert_eq!(err.message(), "Item not found");
356        assert!(matches!(err.error_type(), UtilErrorType::Other));
357    }
358
359    #[test]
360    fn test_util_err_with_format() {
361        let path = "/tmp/test.txt";
362        let err = util_err!(Other, format!("Failed to process file: {}", path));
363        assert_eq!(err.message(), "Failed to process file: /tmp/test.txt");
364    }
365
366    /// The captured `Location` points at the `util_err!` call site, not `macros.rs` or
367    /// the `new` constructor.
368    #[test]
369    fn err_macro_captures_the_real_call_site() {
370        let expected_line = line!() + 1;
371        let err = util_err!(Other, "boom".to_string());
372        let location = err.location().expect("location should be captured");
373        assert_eq!(location.line(), expected_line, "file: {}", location.file());
374        assert!(
375            location.file().ends_with("util_error.rs"),
376            "expected the call site, got: {}",
377            location.file()
378        );
379        assert!(
380            !location.file().contains("macros.rs"),
381            "got: {}",
382            location.file()
383        );
384    }
385
386    /// `Debug` renders the clean format without leaking infra paths.
387    #[test]
388    fn debug_uses_clean_format() {
389        let err = util_err!(Other, "boom".to_string());
390        let debug = format!("{err:?}");
391        assert!(debug.contains("UtilError ยท Other: boom"), "got: {debug}");
392        assert!(!debug.contains("backend_error.rs"), "got: {debug}");
393    }
394}