Skip to main content

ruso_runtime/runtime/
error.rs

1use thiserror::Error;
2
3use crate::contract::QualifiedMatch;
4use crate::runtime::binary::BytecodeError;
5
6#[derive(Debug, Error)]
7pub enum RuntimeError {
8    #[error("bytecode: {0}")]
9    Bytecode(#[from] BytecodeError),
10    #[error("unknown request or probe: {0}")]
11    UnknownTarget(String),
12
13    #[error("request {name} is not HTTP (dns/tcp probe)")]
14    WrongProbeKind { name: String },
15
16    #[error("match failed: {0}")]
17    MatchFailed(String),
18
19    #[error("assertion failed: {0}")]
20    AssertFailed(String),
21
22    #[error("extract failed for variable {name}: {reason}")]
23    ExtractFailed { name: String, reason: String },
24
25    #[error("flow control: {0}")]
26    Flow(String),
27
28    #[error("invalid duration: {0}")]
29    InvalidDuration(String),
30
31    #[error("http error: {0}")]
32    Http(#[from] reqwest::Error),
33
34    #[error("io error: {0}")]
35    Io(#[from] std::io::Error),
36
37    #[error("regex error: {0}")]
38    Regex(#[from] regex::Error),
39
40    #[error("{0}")]
41    Other(String),
42}
43
44impl RuntimeError {
45    pub fn match_failed(matcher: &QualifiedMatch, detail: impl Into<String>) -> Self {
46        Self::MatchFailed(format!("{matcher:?}: {}", detail.into()))
47    }
48
49    pub fn assert_failed(matcher: &QualifiedMatch, detail: impl Into<String>) -> Self {
50        Self::AssertFailed(format!("{matcher:?}: {}", detail.into()))
51    }
52
53    /// The complete error message, including every underlying cause.
54    ///
55    /// `thiserror`'s `Display` renders only this error's own message. For the
56    /// wrapped HTTP and I/O variants the real reason — a rejected TLS
57    /// certificate, a connection reset, a response body that failed to decode —
58    /// lives in the [`source`](std::error::Error::source) chain and would
59    /// otherwise be dropped, leaving an opaque `"http error: …"`. This walks
60    /// that chain and joins it into one line so logs and scan reports carry the
61    /// actual cause.
62    pub fn full_message(&self) -> String {
63        join_source_chain(self)
64    }
65}
66
67/// Render an error and its [`source`](std::error::Error::source) chain as a
68/// single `"top: cause: root-cause"` line.
69///
70/// Only causes that add new text are appended: some errors (notably `reqwest`)
71/// repeat their own `Display` as their first source, which would otherwise
72/// duplicate a segment.
73fn join_source_chain(error: &dyn std::error::Error) -> String {
74    let mut message = error.to_string();
75    let mut next = error.source();
76    while let Some(cause) = next {
77        let cause_text = cause.to_string();
78        if !message.contains(&cause_text) {
79            message.push_str(": ");
80            message.push_str(&cause_text);
81        }
82        next = cause.source();
83    }
84    message
85}
86
87#[cfg(test)]
88mod tests {
89    use super::join_source_chain;
90    use std::error::Error;
91    use std::fmt;
92
93    /// A minimal error whose source chain we control, for exercising
94    /// [`join_source_chain`] without depending on reqwest/io internals.
95    #[derive(Debug)]
96    struct Layer {
97        message: &'static str,
98        source: Option<Box<Layer>>,
99    }
100
101    impl Layer {
102        fn leaf(message: &'static str) -> Box<Self> {
103            Box::new(Self {
104                message,
105                source: None,
106            })
107        }
108        fn wrap(message: &'static str, source: Box<Layer>) -> Self {
109            Self {
110                message,
111                source: Some(source),
112            }
113        }
114    }
115
116    impl fmt::Display for Layer {
117        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118            f.write_str(self.message)
119        }
120    }
121
122    impl Error for Layer {
123        fn source(&self) -> Option<&(dyn Error + 'static)> {
124            self.source.as_deref().map(|s| s as &(dyn Error + 'static))
125        }
126    }
127
128    #[test]
129    fn single_error_has_no_suffix() {
130        let err = Layer {
131            message: "io error",
132            source: None,
133        };
134        assert_eq!(join_source_chain(&err), "io error");
135    }
136
137    #[test]
138    fn joins_each_distinct_cause() {
139        let err = Layer::wrap(
140            "error sending request",
141            Box::new(Layer::wrap(
142                "client error (Connect)",
143                Layer::leaf("invalid peer certificate"),
144            )),
145        );
146        assert_eq!(
147            join_source_chain(&err),
148            "error sending request: client error (Connect): invalid peer certificate"
149        );
150    }
151
152    #[test]
153    fn skips_a_cause_already_present() {
154        // reqwest repeats its top-level Display as its own first source.
155        let err = Layer::wrap(
156            "error sending request",
157            Box::new(Layer::wrap(
158                "error sending request",
159                Layer::leaf("UnknownIssuer"),
160            )),
161        );
162        assert_eq!(
163            join_source_chain(&err),
164            "error sending request: UnknownIssuer"
165        );
166    }
167}