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    SuotarClientError(SuotarErrorVariant),
41}
42#[derive(Debug)]
43
44pub enum SisuErrorVariant {
45    GenericSisuError,
46    InvalidCourseCode,
47    SisuResourceNotFound,
48}
49
50/// How a call to Suotar failed at the request level. Per-item failures are not errors: they come
51/// back inside a successful batch response.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum SuotarErrorVariant {
54    /// Our credentials. Loud, and never attributed to the rows in the batch.
55    Unauthorized,
56    /// Our request. Loud, and never attributed to the rows in the batch.
57    MalformedRequest,
58    /// Another 4xx carrying the documented `{ error: { code, message } }` body.
59    RequestLevelError,
60    ServerError,
61    /// The connection itself failed, so the request provably never arrived.
62    TransportNotDelivered,
63    /// The request left and the answer did not arrive. A timeout is this, not the above.
64    TransportUnknown,
65    /// Suotar answered, and the answer was not a batch response.
66    Deserialization,
67}
68
69impl SuotarErrorVariant {
70    /// Whether Suotar may have acted on the request. An import that may have landed must be
71    /// verified rather than re-sent, or a transcript gets a second attainment.
72    ///
73    /// A 4xx (`Unauthorized`, `MalformedRequest`, `RequestLevelError`) never reached Suotar's
74    /// business logic, so it is as resendable as a connection that never opened
75    /// (`TransportNotDelivered`); a genuine 5xx (`ServerError`), a response that never arrived
76    /// (`TransportUnknown`), or one that arrived malformed (`Deserialization`) all leave the
77    /// outcome unknown.
78    pub fn outcome_may_have_landed(self) -> bool {
79        !matches!(
80            self,
81            Self::Unauthorized
82                | Self::MalformedRequest
83                | Self::RequestLevelError
84                | Self::TransportNotDelivered
85        )
86    }
87}
88
89/**
90Error type used by all models. Used as the error type in [UtilError], which is used by all the controllers in the application.
91
92All 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.
93
94## Examples
95
96### Usage without source error
97
98```no_run
99# use headless_lms_utils::prelude::*;
100# fn random_function() -> UtilResult<()> {
101#    let erroneous_condition = 1 == 1;
102if erroneous_condition {
103    return Err(UtilError::new(
104        UtilErrorType::Other,
105        "File not found".to_string(),
106        None,
107    ));
108}
109# Ok(())
110# }
111```
112
113### Usage with a source error
114
115Used when calling a function that returns an error that cannot be automatically converted to an UtilError. (See `impl From<X>` implementations on this struct.)
116
117```no_run
118# use headless_lms_utils::prelude::*;
119# fn some_function_returning_an_error() -> UtilResult<()> {
120#    return Err(UtilError::new(
121#        UtilErrorType::Other,
122#        "File not found".to_string(),
123#        None,
124#    ));
125# }
126#
127# fn random_function() -> UtilResult<()> {
128#    let erroneous_condition = 1 == 1;
129some_function_returning_an_error().map_err(|original_error| {
130    UtilError::new(
131        UtilErrorType::Other,
132        "Library x failed to do y".to_string(),
133        Some(original_error.into()),
134    )
135})?;
136# Ok(())
137# }
138```
139*/
140pub struct UtilError {
141    error_type: <UtilError as BackendError>::ErrorType,
142    message: String,
143    /// Original error that caused this error.
144    source: Option<anyhow::Error>,
145    /// A trace of tokio tracing spans, generated automatically when the error is generated.
146    span_trace: Box<SpanTrace>,
147    /// Stack trace, generated automatically when the error is created.
148    backtrace: Box<Backtrace>,
149    /// Source location where the error was raised.
150    location: Option<&'static Location<'static>>,
151}
152
153impl std::error::Error for UtilError {
154    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
155        self.source
156            .as_deref()
157            .map(|e| e as &(dyn std::error::Error + 'static))
158    }
159
160    fn cause(&self) -> Option<&dyn std::error::Error> {
161        self.source()
162    }
163}
164
165// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
166headless_lms_base::impl_clean_debug!(UtilError, [UtilError]);
167
168impl Display for UtilError {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        write!(f, "UtilError {:?} {:?}", self.error_type, self.message)
171    }
172}
173
174impl BackendError for UtilError {
175    type ErrorType = UtilErrorType;
176
177    fn backtrace(&self) -> Option<&Backtrace> {
178        Some(&self.backtrace)
179    }
180
181    fn error_type(&self) -> &Self::ErrorType {
182        &self.error_type
183    }
184
185    fn message(&self) -> &str {
186        &self.message
187    }
188
189    fn span_trace(&self) -> &SpanTrace {
190        &self.span_trace
191    }
192
193    fn location(&self) -> Option<&'static Location<'static>> {
194        self.location
195    }
196
197    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
198        error_type: Self::ErrorType,
199        message: M,
200        source_error: S,
201        backtrace: Backtrace,
202        span_trace: SpanTrace,
203        location: Option<&'static Location<'static>>,
204    ) -> Self {
205        Self {
206            error_type,
207            message: message.into(),
208            source: source_error.into(),
209            span_trace: Box::new(span_trace),
210            backtrace: Box::new(backtrace),
211            location,
212        }
213    }
214}
215
216impl From<url::ParseError> for UtilError {
217    fn from(source: url::ParseError) -> Self {
218        UtilError::new(
219            UtilErrorType::UrlParse,
220            source.to_string(),
221            Some(source.into()),
222        )
223    }
224}
225
226impl From<walkdir::Error> for UtilError {
227    fn from(source: walkdir::Error) -> Self {
228        UtilError::new(
229            UtilErrorType::Walkdir,
230            source.to_string(),
231            Some(source.into()),
232        )
233    }
234}
235
236impl From<reqwest::Error> for UtilError {
237    fn from(err: reqwest::Error) -> UtilError {
238        Self::new(
239            UtilErrorType::ReqwestError,
240            err.to_string(),
241            Some(err.into()),
242        )
243    }
244}
245
246impl From<std::path::StripPrefixError> for UtilError {
247    fn from(source: std::path::StripPrefixError) -> Self {
248        UtilError::new(
249            UtilErrorType::StripPrefix,
250            source.to_string(),
251            Some(source.into()),
252        )
253    }
254}
255
256impl From<tokio::io::Error> for UtilError {
257    fn from(source: tokio::io::Error) -> Self {
258        UtilError::new(
259            UtilErrorType::TokioIo,
260            source.to_string(),
261            Some(source.into()),
262        )
263    }
264}
265
266impl From<serde_json::Error> for UtilError {
267    fn from(source: serde_json::Error) -> Self {
268        UtilError::new(
269            UtilErrorType::SerdeJson,
270            source.to_string(),
271            Some(source.into()),
272        )
273    }
274}
275
276impl From<google_cloud_storage::Error> for UtilError {
277    fn from(source: google_cloud_storage::Error) -> Self {
278        UtilError::new(
279            UtilErrorType::CloudStorage,
280            source.to_string(),
281            Some(source.into()),
282        )
283    }
284}
285
286impl From<anyhow::Error> for UtilError {
287    fn from(err: anyhow::Error) -> UtilError {
288        Self::new(UtilErrorType::Other, err.to_string(), Some(err))
289    }
290}
291
292// Generate error creation macros for UtilError
293crate::define_err_macro!(
294    util_err,
295    UtilError,
296    UtilErrorType,
297    UtilErrorType,
298    "Create a UtilError with less boilerplate."
299);
300
301/// Helper function for `.map_err()` chains to wrap any error as UtilError.
302///
303/// This function creates a closure that converts any error into a `UtilError`
304/// with the specified error type and message, including the original error as the source.
305///
306/// # Examples
307///
308/// ```ignore
309/// // Instead of:
310/// .map_err(|e| UtilError::new(UtilErrorType::Other, e.to_string(), Some(e.into())))?
311///
312/// // You can write:
313/// .map_err(as_util_error(UtilErrorType::Other, "Failed to process".to_string()))?
314/// ```
315pub fn as_util_error<E>(
316    error_type: UtilErrorType,
317    message: impl Into<String>,
318) -> impl FnOnce(E) -> UtilError
319where
320    E: Into<anyhow::Error>,
321{
322    let msg = message.into();
323    move |e| UtilError::new(error_type, msg, Some(e.into()))
324}
325
326/// Helper function for `.ok_or_else()` to create UtilError on None.
327///
328/// This function creates a closure that generates a `UtilError` with the
329/// specified error type and message when called.
330///
331/// # Examples
332///
333/// ```ignore
334/// // Instead of:
335/// .ok_or_else(|| UtilError::new(UtilErrorType::Other, "Item not found".to_string(), None))
336///
337/// // You can write:
338/// .ok_or_else(missing_util_error(UtilErrorType::Other, "Item not found".to_string()))
339/// ```
340pub fn missing_util_error(
341    error_type: UtilErrorType,
342    message: impl Into<String>,
343) -> impl FnOnce() -> UtilError {
344    let msg = message.into();
345    move || UtilError::new(error_type, msg, None)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_util_err_macro_without_source() {
354        let err = util_err!(Other, "Test error message".to_string());
355        assert_eq!(err.message(), "Test error message");
356        assert!(matches!(err.error_type(), UtilErrorType::Other));
357    }
358
359    #[test]
360    fn test_util_err_macro_with_source() {
361        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
362        let err = util_err!(TokioIo, "Wrapped error".to_string(), source_err);
363        assert_eq!(err.message(), "Wrapped error");
364    }
365
366    #[test]
367    fn test_as_util_error_helper() {
368        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
369            std::io::ErrorKind::NotFound,
370            "test error",
371        ));
372        let util_result = result.map_err(as_util_error(
373            UtilErrorType::TokioIo,
374            "Failed to read file".to_string(),
375        ));
376
377        assert!(util_result.is_err());
378        let err = util_result.unwrap_err();
379        assert_eq!(err.message(), "Failed to read file");
380        assert!(matches!(err.error_type(), UtilErrorType::TokioIo));
381    }
382
383    #[test]
384    fn test_missing_util_error_helper() {
385        let option: Option<String> = None;
386        let result = option.ok_or_else(missing_util_error(
387            UtilErrorType::Other,
388            "Item not found".to_string(),
389        ));
390
391        assert!(result.is_err());
392        let err = result.unwrap_err();
393        assert_eq!(err.message(), "Item not found");
394        assert!(matches!(err.error_type(), UtilErrorType::Other));
395    }
396
397    #[test]
398    fn test_util_err_with_format() {
399        let path = "/tmp/test.txt";
400        let err = util_err!(Other, format!("Failed to process file: {}", path));
401        assert_eq!(err.message(), "Failed to process file: /tmp/test.txt");
402    }
403
404    /// The captured `Location` points at the `util_err!` call site, not `macros.rs` or
405    /// the `new` constructor.
406    #[test]
407    fn err_macro_captures_the_real_call_site() {
408        let expected_line = line!() + 1;
409        let err = util_err!(Other, "boom".to_string());
410        let location = err.location().expect("location should be captured");
411        assert_eq!(location.line(), expected_line, "file: {}", location.file());
412        assert!(
413            location.file().ends_with("util_error.rs"),
414            "expected the call site, got: {}",
415            location.file()
416        );
417        assert!(
418            !location.file().contains("macros.rs"),
419            "got: {}",
420            location.file()
421        );
422    }
423
424    /// `Debug` renders the clean format without leaking infra paths.
425    #[test]
426    fn debug_uses_clean_format() {
427        let err = util_err!(Other, "boom".to_string());
428        let debug = format!("{err:?}");
429        assert!(debug.contains("UtilError ยท Other: boom"), "got: {debug}");
430        assert!(!debug.contains("backend_error.rs"), "got: {debug}");
431    }
432}