Skip to main content

headless_lms_base/error/clean_format/
mod.rs

1/*!
2Clean, human-readable rendering of backend errors for developers.
3
4Renders an error and its cause chain as sectioned plain text (never JSON): per error, its
5type, message, raise location and an our-code-only stack. Third-party causes are shown
6message-only and tagged `(external)`. A one-line breadcrumb of the active tracing spans
7is appended.
8
9Example output:
10
11```text
12ChatbotError · StreamingError: Stream ended unexpectedly: rate limit for gpt-5.5
13  at chatbot/src/azure_chatbot.rs:824  send_chat_request_and_parse_stream
14     server/src/controllers/chatbot.rs:120  send_message
15     ⋯ 12 framework frames hidden ⋯
16
17caused by:
18  1. ModelError · Database: connection pool timed out
19     at models/src/chatbot/conversations.rs:45  get_conversation
20        ⋯ 9 framework frames hidden ⋯
21  2. pool timed out while waiting for connection  (external)
22
23spans
24  http_request{request_id=aea0…} › send_message{course_id=5d79…}
25```
26
27## Design
28
29The four error types live in crates that depend on `base`, so `base` cannot name them.
30They expose their data through the object-safe [`ErrorTrace`] trait (blanket-implemented
31for every [`BackendError`]); each type's generated `Debug`/`clean_string` passes a
32crate-local downcast resolver (see [`crate::impl_clean_debug`]). A cause chain only
33contains error types below a given error in the dependency graph, so a per-crate resolver
34covers every reachable cause without a runtime registry.
35*/
36
37pub mod color;
38pub mod frames;
39pub mod spans;
40
41use core::fmt;
42use std::panic::Location;
43
44use backtrace::Backtrace;
45use tracing_error::SpanTrace;
46
47pub use color::ColorChoice;
48
49use crate::error::backend_error::BackendError;
50use color::{bold, dim};
51
52/// Object-safe view over an error, letting `base` render types defined in dependent crates.
53pub trait ErrorTrace {
54    /// Short type name, e.g. `"ChatbotError"`.
55    fn type_name(&self) -> &'static str;
56    /// Error-type variant, via its `Debug`.
57    fn variant(&self) -> String;
58    /// Human-facing message.
59    fn message(&self) -> &str;
60    /// Captured OS backtrace, if any.
61    fn backtrace(&self) -> Option<&Backtrace>;
62    /// Captured raise location, if any.
63    fn location(&self) -> Option<&'static Location<'static>>;
64    /// Captured tracing span trace.
65    fn span_trace(&self) -> &SpanTrace;
66}
67
68/// Every [`BackendError`] is an [`ErrorTrace`]. The type name is derived from
69/// [`std::any::type_name`] and reduced to its last path segment.
70impl<T: BackendError> ErrorTrace for T {
71    fn type_name(&self) -> &'static str {
72        let full = std::any::type_name::<T>();
73        full.rsplit("::").next().unwrap_or(full)
74    }
75
76    fn variant(&self) -> String {
77        format!("{:?}", BackendError::error_type(self))
78    }
79
80    fn message(&self) -> &str {
81        BackendError::message(self)
82    }
83
84    fn backtrace(&self) -> Option<&Backtrace> {
85        BackendError::backtrace(self)
86    }
87
88    fn location(&self) -> Option<&'static Location<'static>> {
89        BackendError::location(self)
90    }
91
92    fn span_trace(&self) -> &SpanTrace {
93        BackendError::span_trace(self)
94    }
95}
96
97/// A resolver turns a type-erased cause into an [`ErrorTrace`] if it is one of our
98/// errors. Generated per crate by [`crate::impl_clean_debug`].
99pub type Resolver<'a> =
100    dyn for<'e> Fn(&'e (dyn std::error::Error + 'static)) -> Option<&'e dyn ErrorTrace> + 'a;
101
102/// Bound on cause-chain length, guarding against cycles.
103const MAX_CHAIN: usize = 64;
104
105/// Render `head` and its whole cause chain in the clean developer format.
106pub fn render(
107    out: &mut dyn fmt::Write,
108    head: &dyn ErrorTrace,
109    head_source: Option<&(dyn std::error::Error + 'static)>,
110    resolve: &Resolver<'_>,
111    color: ColorChoice,
112) -> fmt::Result {
113    let colored = color.enabled();
114
115    write_node(out, head, "  ", colored)?;
116
117    let mut current = head_source;
118    let mut index = 1usize;
119    let mut started_chain = false;
120    while let Some(err) = current {
121        if index > MAX_CHAIN {
122            break;
123        }
124        if !started_chain {
125            writeln!(out)?;
126            writeln!(out, "{}", bold("caused by:", colored))?;
127            started_chain = true;
128        }
129        match resolve(err) {
130            Some(trace) => write_cause_node(out, index, trace, colored)?,
131            None => writeln!(out, "  {index}. {err}  {}", dim("(external)", colored))?,
132        }
133        index += 1;
134        current = err.source();
135    }
136
137    if let Some(breadcrumb) = spans::breadcrumb(head.span_trace()) {
138        writeln!(out)?;
139        writeln!(out, "{}", bold("spans", colored))?;
140        writeln!(out, "  {breadcrumb}")?;
141    }
142
143    Ok(())
144}
145
146fn header_line(trace: &dyn ErrorTrace, colored: bool) -> String {
147    format!(
148        "{} · {}: {}",
149        bold(trace.type_name(), colored),
150        trace.variant(),
151        trace.message()
152    )
153}
154
155fn write_node(
156    out: &mut dyn fmt::Write,
157    trace: &dyn ErrorTrace,
158    indent: &str,
159    colored: bool,
160) -> fmt::Result {
161    writeln!(out, "{}", header_line(trace, colored))?;
162    write_stack(out, trace, indent, colored)
163}
164
165fn write_cause_node(
166    out: &mut dyn fmt::Write,
167    index: usize,
168    trace: &dyn ErrorTrace,
169    colored: bool,
170) -> fmt::Result {
171    writeln!(out, "  {index}. {}", header_line(trace, colored))?;
172    write_stack(out, trace, "     ", colored)
173}
174
175fn write_stack(
176    out: &mut dyn fmt::Write,
177    trace: &dyn ErrorTrace,
178    indent: &str,
179    colored: bool,
180) -> fmt::Result {
181    // Only use the captured location when it is meaningful, i.e. not inside error
182    // infrastructure (e.g. a `From` body).
183    let raise_override = trace
184        .location()
185        .filter(|location| !frames::is_infra_path(location.file()))
186        .map(|location| (location.file(), location.line()));
187
188    match trace.backtrace() {
189        Some(backtrace) => {
190            let extracted = frames::extract_frames(backtrace);
191            frames::render_stack(out, &extracted, raise_override, indent, colored)
192        }
193        None => {
194            if let Some((file, line)) = raise_override {
195                writeln!(
196                    out,
197                    "{indent}{} {}:{}",
198                    dim("at", colored),
199                    frames::clean_path(file),
200                    line
201                )?;
202            }
203            Ok(())
204        }
205    }
206}
207
208/// Generate the clean `Debug`, a `clean_string` helper and a crate-local cause resolver
209/// for an error type.
210///
211/// The list is every error type that can appear in the cause chain (this type plus every
212/// `BackendError` it can wrap); order is irrelevant.
213///
214/// ```ignore
215/// headless_lms_base::impl_clean_debug!(ChatbotError, [ChatbotError, ModelError, UtilError]);
216/// ```
217#[macro_export]
218macro_rules! impl_clean_debug {
219    ($error:ty, [ $( $cause:ty ),* $(,)? ]) => {
220        impl $error {
221            fn render_clean_error(
222                &self,
223                out: &mut dyn ::core::fmt::Write,
224                color: $crate::error::clean_format::ColorChoice,
225            ) -> ::core::fmt::Result {
226                fn resolve<'err>(
227                    err: &'err (dyn ::std::error::Error + 'static),
228                ) -> ::core::option::Option<&'err dyn $crate::error::clean_format::ErrorTrace> {
229                    $(
230                        if let ::core::option::Option::Some(matched) =
231                            err.downcast_ref::<$cause>()
232                        {
233                            return ::core::option::Option::Some(
234                                matched as &dyn $crate::error::clean_format::ErrorTrace,
235                            );
236                        }
237                    )*
238                    ::core::option::Option::None
239                }
240                $crate::error::clean_format::render(
241                    out,
242                    self as &dyn $crate::error::clean_format::ErrorTrace,
243                    ::std::error::Error::source(self),
244                    &resolve,
245                    color,
246                )
247            }
248
249            /// Render this error in the clean developer format.
250            pub fn clean_string(
251                &self,
252                color: $crate::error::clean_format::ColorChoice,
253            ) -> ::std::string::String {
254                let mut buffer = ::std::string::String::new();
255                let _ = self.render_clean_error(&mut buffer, color);
256                buffer
257            }
258        }
259
260        impl ::core::fmt::Debug for $error {
261            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
262                self.render_clean_error(f, $crate::error::clean_format::ColorChoice::Never)
263            }
264        }
265    };
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    /// Minimal `ErrorTrace` (not a `BackendError`) for building cause chains in tests.
273    struct FakeError {
274        type_name: &'static str,
275        variant: &'static str,
276        message: String,
277        source: Option<Box<dyn std::error::Error + 'static>>,
278        span_trace: SpanTrace,
279    }
280
281    impl std::fmt::Display for FakeError {
282        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283            write!(f, "{}", self.message)
284        }
285    }
286    impl std::fmt::Debug for FakeError {
287        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288            write!(f, "{}", self.message)
289        }
290    }
291    impl std::error::Error for FakeError {
292        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
293            self.source.as_deref()
294        }
295    }
296    impl ErrorTrace for FakeError {
297        fn type_name(&self) -> &'static str {
298            self.type_name
299        }
300        fn variant(&self) -> String {
301            self.variant.to_string()
302        }
303        fn message(&self) -> &str {
304            &self.message
305        }
306        fn backtrace(&self) -> Option<&Backtrace> {
307            None
308        }
309        fn location(&self) -> Option<&'static Location<'static>> {
310            None
311        }
312        fn span_trace(&self) -> &SpanTrace {
313            &self.span_trace
314        }
315    }
316
317    fn resolver<'err>(
318        err: &'err (dyn std::error::Error + 'static),
319    ) -> Option<&'err dyn ErrorTrace> {
320        err.downcast_ref::<FakeError>()
321            .map(|e| e as &dyn ErrorTrace)
322    }
323
324    fn render_to_string(head: &FakeError) -> String {
325        let mut out = String::new();
326        render(
327            &mut out,
328            head,
329            std::error::Error::source(head),
330            &resolver,
331            ColorChoice::Never,
332        )
333        .unwrap();
334        out
335    }
336
337    #[test]
338    fn renders_header_and_full_chain_without_skipping_levels() {
339        // Chain: ChatbotError (head) -> ModelError -> external leaf.
340        let leaf = FakeError {
341            type_name: "io::Error",
342            variant: "",
343            message: "pool timed out".to_string(),
344            source: None,
345            span_trace: SpanTrace::capture(),
346        };
347        // Wrap the leaf in a plain (non-FakeError) error to exercise the (external) path.
348        let external: Box<dyn std::error::Error + 'static> =
349            Box::new(std::io::Error::other(leaf.message.clone()));
350        let model = FakeError {
351            type_name: "ModelError",
352            variant: "Database",
353            message: "database call failed".to_string(),
354            source: Some(external),
355            span_trace: SpanTrace::capture(),
356        };
357        let head = FakeError {
358            type_name: "ChatbotError",
359            variant: "StreamingError",
360            message: "stream ended".to_string(),
361            source: Some(Box::new(model)),
362            span_trace: SpanTrace::capture(),
363        };
364
365        let out = render_to_string(&head);
366
367        assert!(
368            out.contains("ChatbotError · StreamingError: stream ended"),
369            "{out}"
370        );
371        assert!(out.contains("caused by:"), "{out}");
372        assert!(
373            out.contains("1. ModelError · Database: database call failed"),
374            "{out}"
375        );
376        // The leaf is not a FakeError, so it takes the (external) path and must still appear.
377        assert!(out.contains("2. pool timed out  (external)"), "{out}");
378    }
379
380    #[test]
381    fn no_chain_section_without_a_source() {
382        let head = FakeError {
383            type_name: "UtilError",
384            variant: "Other",
385            message: "boom".to_string(),
386            source: None,
387            span_trace: SpanTrace::capture(),
388        };
389        let out = render_to_string(&head);
390        assert!(out.contains("UtilError · Other: boom"), "{out}");
391        assert!(!out.contains("caused by:"), "{out}");
392    }
393}