headless_lms_utils/error/
util_error.rs1use std::fmt::Display;
6use std::panic::Location;
7
8use backtrace::Backtrace;
9use headless_lms_base::error::backend_error::BackendError;
10use tracing_error::SpanTrace;
11pub type UtilResult<T> = Result<T, UtilError>;
17
18#[derive(Debug)]
20pub enum UtilErrorType {
21 UrlParse,
22 Walkdir,
23 StripPrefix,
24 TokioIo,
25 SerdeJson,
26 CloudStorage,
27 Other,
28 Unavailable,
29 DeserializationError,
30 TmcHttpError,
33 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum SuotarErrorVariant {
54 Unauthorized,
56 MalformedRequest,
58 RequestLevelError,
60 ServerError,
61 TransportNotDelivered,
63 TransportUnknown,
65 Deserialization,
67}
68
69impl SuotarErrorVariant {
70 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
89pub struct UtilError {
141 error_type: <UtilError as BackendError>::ErrorType,
142 message: String,
143 source: Option<anyhow::Error>,
145 span_trace: Box<SpanTrace>,
147 backtrace: Box<Backtrace>,
149 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
165headless_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
292crate::define_err_macro!(
294 util_err,
295 UtilError,
296 UtilErrorType,
297 UtilErrorType,
298 "Create a UtilError with less boilerplate."
299);
300
301pub 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
326pub 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 #[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 #[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}