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}