Skip to main content

zenkey_fleet/
error.rs

1//! The engine's failure surface — one type, classified by what a caller can
2//! do about it.
3//!
4//! Every public function in this crate returned `anyhow::Result` until #348.
5//! For a binary that is fine; for a **published library** it means a consumer
6//! cannot tell a bad key expression from a dead bus without matching on the
7//! text of a sentence, and the one consumer that most needs to tell them
8//! apart is `zenctl`, whose exit codes are a wire surface CI branches on.
9//!
10//! ## The classification is the point
11//!
12//! RFC 13 §1 draws the line this enum is built around: **a question that
13//! could not be *put* is not the same as a question that was put and
14//! answered badly.** [`Error::Unaskable`] is the first; everything else is a
15//! failure of an attempt that was actually made.
16//!
17//! That distinction had a real cost while it was untyped. `zenctl registry
18//! lint /nonexistent` exited **1** — "asked, and the answer is a finding" —
19//! telling CI that a registry had lint findings when in fact the directory
20//! was not there. It exited 1 because `exit::code_for` walks the error chain
21//! looking for `zenctl`'s own `Unaskable` marker, and an engine error carried
22//! no marker at all. The engine knows perfectly well which of its failures
23//! are refusals of the caller's input; it just had nowhere to say so.
24//!
25//! ## What each variant means
26//!
27//! | Variant | The caller should | `zenctl` exits |
28//! |---|---|---|
29//! | [`Unaskable`](Error::Unaskable) | fix the input | 2 — no verdict |
30//! | [`Bus`](Error::Bus) | retry, or check the fleet | 1, or 2 on a verdict verb |
31//! | [`Io`](Error::Io) | check the path | 1, or 2 on a verdict verb |
32//! | [`Malformed`](Error::Malformed) | distrust the peer | 1 |
33//! | [`Internal`](Error::Internal) | file a bug against this crate | 1 |
34//!
35//! ## How these render
36//!
37//! **`Display` says what failed; `source()` says why**, and a renderer joins
38//! them — `zenctl::errors::render` prints `Error: …` then an indented
39//! `Caused by:` list, trimming zenoh's build-machine source locations on the
40//! way. So no variant's `Display` repeats the text of its own source: doing
41//! that once produced `registry dir "/x": No such file` immediately followed
42//! by `Caused by: No such file`, which the CLI corpus caught.
43//!
44//! The exception is deliberate. [`Unaskable`](Error::Unaskable) inlines its
45//! cause's text and keeps no `source`, because those causes are one-line
46//! refusals ("`*` may only be preceded by `/`") whose entire content *is*
47//! the sentence — there is nothing structured under them to reach for.
48
49use std::path::PathBuf;
50
51/// A cause from somewhere else, kept rather than flattened to text.
52///
53/// Boxed because the causes are genuinely unrelated types — `zenoh::Error`
54/// (itself a box), `std::io::Error`, `serde_json::Error`, a `JoinError`.
55pub type BoxedCause = Box<dyn std::error::Error + Send + Sync>;
56
57/// [`Error::Io`]'s Display, kept total (see the variant's own doc).
58fn path_or_local_io(path: &std::path::Path) -> String {
59    match path.as_os_str().is_empty() {
60        true => "local I/O".to_string(),
61        false => path.display().to_string(),
62    }
63}
64
65/// Why a fleet operation could not answer.
66#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum Error {
69    /// **The caller's input was refused; nothing was attempted.** A selector
70    /// that is not a key expression, a name outside a closed vocabulary, a
71    /// window of zero seconds, a hostname where an origin belongs.
72    ///
73    /// This is the variant that carries RFC 13 §1's "the question could not
74    /// be asked", and the only one a caller fixes by changing what it passed.
75    #[error("{what}: {detail}")]
76    Unaskable {
77        /// What was refused, as the caller named it — a selector, a flag
78        /// value, an id.
79        what: String,
80        /// Why. This crate's own words, or a one-line parse refusal inlined
81        /// from whatever produced it (see the module doc on rendering).
82        detail: String,
83    },
84
85    /// A bus operation failed: a declare, a get, a put, an undeclare.
86    ///
87    /// The question was asked and the fabric did not carry it. Distinct from
88    /// [`Unaskable`](Error::Unaskable) because retrying is meaningful.
89    #[error("{op} {target}")]
90    Bus {
91        /// The operation, phrased so that `{op} {target}` reads as the thing
92        /// that failed — `subscribe v1/**`, `declare queryable …`, `failed to
93        /// open the Zenoh session`. It is a label, not a zenoh API name.
94        op: &'static str,
95        /// What it was against — a key expression, a selector, an origin.
96        target: String,
97        #[source]
98        source: BoxedCause,
99    },
100
101    /// Local filesystem I/O — a `.zrec`, a registry directory, a config.
102    ///
103    /// Carries the path where there is one, and the `io::ErrorKind` is
104    /// reachable through [`source`](std::error::Error::source): "no such
105    /// directory" and "permission denied" are different answers to give a
106    /// user.
107    ///
108    /// The path may be **empty**, and the Display says so rather than
109    /// rendering to nothing: a writer generic over `W: Write` (the `.zrec`
110    /// sink) genuinely does not know where its bytes are going. An error
111    /// whose `Display` is the empty string is not a smaller error — it is a
112    /// blank `Error:` line and a blank `Caused by:` under it.
113    #[error("{}", path_or_local_io(path))]
114    Io {
115        path: PathBuf,
116        #[source]
117        source: std::io::Error,
118    },
119
120    /// Bytes arrived and could not be read as what they claim to be — a
121    /// `.zrec` header, a served slice, a reply payload.
122    ///
123    /// Not the caller's fault and not the fabric's: a peer said something
124    /// this build cannot parse.
125    #[error("{what}: {detail}")]
126    Malformed {
127        what: String,
128        /// This crate's own words — short, because the parser's own message
129        /// rides underneath as the [`source`](std::error::Error::source).
130        detail: String,
131        #[source]
132        source: Option<BoxedCause>,
133    },
134
135    /// An invariant of *this crate* did not hold.
136    ///
137    /// Every construction site is a place the old code said "a bug in
138    /// `blob_*`" or "tasks outlived the run". Kept as a variant rather than a
139    /// panic because an explorer that has found a bug in its own engine
140    /// should still be able to report it rather than die mid-sweep.
141    #[error("internal: {0} — please report this against zenkey-fleet")]
142    Internal(String),
143}
144
145/// This crate's result type.
146pub type Result<T, E = Error> = std::result::Result<T, E>;
147
148impl Error {
149    /// The caller's input was refused and nothing was attempted — RFC 13
150    /// §1's "the question could not be asked".
151    ///
152    /// `zenctl` maps this to its reserved exit 2. It is a method rather than
153    /// a `matches!` at the call site so the mapping has one home, the way
154    /// [`judgement_exit_code`](crate::judgement_exit_code) does for verdicts.
155    pub fn is_unaskable(&self) -> bool {
156        matches!(self, Error::Unaskable { .. })
157    }
158
159    /// An input this crate refuses.
160    pub fn unaskable(what: impl Into<String>, detail: impl Into<String>) -> Error {
161        Error::Unaskable {
162            what: what.into(),
163            detail: detail.into(),
164        }
165    }
166
167    /// An input this crate refuses, explained by the error that refused it —
168    /// a key-expression parse, a number that would not parse.
169    pub fn unaskable_from(what: impl Into<String>, cause: impl Into<BoxedCause>) -> Error {
170        Error::Unaskable {
171            what: what.into(),
172            detail: cause.into().to_string(),
173        }
174    }
175
176    /// A bus operation that failed.
177    pub fn bus(op: &'static str, target: impl Into<String>, cause: impl Into<BoxedCause>) -> Error {
178        Error::Bus {
179            op,
180            target: target.into(),
181            source: cause.into(),
182        }
183    }
184
185    /// Bytes that did not read as what they claimed to be.
186    pub fn malformed(what: impl Into<String>, detail: impl Into<String>) -> Error {
187        Error::Malformed {
188            what: what.into(),
189            detail: detail.into(),
190            source: None,
191        }
192    }
193
194    /// Likewise, keeping the parser's own error underneath — where a span or
195    /// a line number is worth reaching for.
196    pub fn malformed_from(what: impl Into<String>, cause: impl Into<BoxedCause>) -> Error {
197        Error::malformed_with(what, "does not parse", cause)
198    }
199
200    /// [`malformed_from`](Error::malformed_from) where the caller has a better
201    /// sentence than "does not parse" — `is not a header`, `is not a slice`.
202    pub fn malformed_with(
203        what: impl Into<String>,
204        detail: impl Into<String>,
205        cause: impl Into<BoxedCause>,
206    ) -> Error {
207        Error::Malformed {
208            what: what.into(),
209            detail: detail.into(),
210            source: Some(cause.into()),
211        }
212    }
213
214    /// Local I/O against a named path.
215    pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Error {
216        Error::Io {
217            path: path.into(),
218            source,
219        }
220    }
221}
222
223/// A served slice that does not parse is a *peer* saying something
224/// unreadable, never a local mistake — RFC 08 §6's introspect reply.
225impl From<zenkey::slice::SliceError> for Error {
226    fn from(e: zenkey::slice::SliceError) -> Error {
227        Error::malformed_from("registry slice", e)
228    }
229}
230
231/// A key this crate built or was handed does not parse.
232impl From<zenkey::KeyError> for Error {
233    fn from(e: zenkey::KeyError) -> Error {
234        Error::unaskable_from("key", e)
235    }
236}
237
238/// This error and every cause beneath it, joined by `: ` — one line.
239///
240/// The counterpart to the rendering convention in the module doc: `Display`
241/// deliberately says only *what* failed, so anywhere that needs the whole
242/// story on one line (a log line, a degradation note, a per-item teardown
243/// report) asks for it here rather than reaching for `{:#}` — which does
244/// nothing on a `thiserror` type and silently drops the cause.
245pub fn one_line(e: &(dyn std::error::Error + 'static)) -> String {
246    let mut out = e.to_string();
247    let mut cause = e.source();
248    while let Some(c) = cause {
249        let text = c.to_string();
250        // A cause whose text the parent already contains adds nothing.
251        if !out.contains(&text) {
252            out.push_str(": ");
253            out.push_str(&text);
254        }
255        cause = c.source();
256    }
257    out
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    /// The classification is the contract (#348): `zenctl` maps
265    /// [`Error::is_unaskable`] onto its reserved exit 2, so which variants
266    /// answer yes is a wire-surface decision, not an implementation detail.
267    #[test]
268    fn only_a_refused_input_is_unaskable() {
269        assert!(Error::unaskable("v1/$*/**", "is not a key expression").is_unaskable());
270        assert!(Error::unaskable_from("--old-root", "bad expr").is_unaskable());
271
272        // Everything else was *attempted*. Reporting these as "could not ask"
273        // would excuse a real failure; reporting the one above as a finding
274        // claims a verdict on a question nobody put.
275        assert!(!Error::bus("subscribe", "v1/**", "no route").is_unaskable());
276        assert!(!Error::io("/tmp/x", std::io::Error::other("nope")).is_unaskable());
277        assert!(!Error::malformed(".zrec", "is not a header").is_unaskable());
278        assert!(!Error::Internal("a bug".into()).is_unaskable());
279    }
280
281    /// `Display` says what failed; `source` says why; `one_line` joins them.
282    ///
283    /// No variant's `Display` may be empty. The `.zrec` writer is generic
284    /// over `W: Write` and has no path to name, so it builds `Error::Io`
285    /// with `PathBuf::new()` — which rendered to the empty string, and
286    /// surfaced a disk-full mid-capture as a blank `Error:` with a blank
287    /// `Caused by:` under it.
288    #[test]
289    fn no_display_is_blank() {
290        let cases = [
291            Error::io(
292                std::path::PathBuf::new(),
293                std::io::Error::other("disk full"),
294            ),
295            Error::io("/tmp/x", std::io::Error::other("disk full")),
296            Error::bus("subscribe", "v1/**", "no route"),
297            Error::unaskable_from("v1/$*/**", "`*` may only follow `/`"),
298            Error::Internal("something".into()),
299        ];
300        for e in &cases {
301            assert!(!e.to_string().is_empty(), "blank Display: {e:?}");
302            assert!(!one_line(e).starts_with(':'), "blank head: {}", one_line(e));
303        }
304        assert_eq!(
305            one_line(&Error::io(
306                std::path::PathBuf::new(),
307                std::io::Error::other("disk full")
308            )),
309            "local I/O: disk full"
310        );
311    }
312
313    /// No variant's `Display` may repeat its own source's text — doing that
314    /// once produced `registry dir "/x": No such file` followed immediately
315    /// by `Caused by: No such file`, which the CLI corpus caught.
316    #[test]
317    fn display_never_repeats_its_own_source() {
318        use std::error::Error as _;
319
320        let io = Error::io("/tmp/x", std::io::Error::other("no such thing"));
321        assert_eq!(io.to_string(), "/tmp/x");
322        assert_eq!(io.source().unwrap().to_string(), "no such thing");
323        assert_eq!(one_line(&io), "/tmp/x: no such thing");
324
325        let bus = Error::bus("subscribe", "v1/**", "no route to host");
326        assert_eq!(bus.to_string(), "subscribe v1/**");
327        assert_eq!(one_line(&bus), "subscribe v1/**: no route to host");
328
329        // An `Unaskable` inlines its cause and keeps none, so there is
330        // nothing for `one_line` to add.
331        let refused = Error::unaskable_from("v1/$*/**", "`*` may only follow `/`");
332        assert!(refused.source().is_none());
333        assert_eq!(one_line(&refused), refused.to_string());
334    }
335
336    /// A slice that does not parse is the *peer* being unreadable — never the
337    /// caller's input, and never the fabric.
338    #[test]
339    fn a_bad_slice_is_malformed_not_unaskable() {
340        let e: Error = zenkey::parse_slice("this is not = = toml")
341            .unwrap_err()
342            .into();
343        assert!(matches!(e, Error::Malformed { .. }));
344        assert!(!e.is_unaskable());
345    }
346}