1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
//! Deterministic-error classification for the CLI exit contract.
//!
//! Retry wrappers around anodizer (CI actions, shell loops) need to tell a
//! transient failure (network flake, registry 5xx — worth retrying) from a
//! deterministic one (unparseable config, invalid flag values, the
//! dist-not-empty guard — retrying burns attempts on an identical failure).
//! Two signals carry that classification:
//!
//! * **exit code [`EXIT_DETERMINISTIC`] (2)** — the Unix usage-error
//! convention; everything else keeps exit 1.
//! * **stderr marker [`CLASS_MARKER`]** — a machine-readable line for
//! wrappers that cannot rely on the exit code (or pin an older anodizer
//! whose deterministic paths still exited 1).
//!
//! Classification is a conservative allowlist: an error is deterministic
//! only if its construction site wrapped it via [`deterministic`] /
//! [`deterministic_msg`]. Anything unwrapped — however config-shaped its
//! message looks — stays exit 1, so a transient failure can never be
//! misfiled as unretryable.
use std::fmt;
/// Exit code for deterministic config/usage errors (Unix usage-error
/// convention, and the code clap already uses for flag-parse errors).
pub const EXIT_DETERMINISTIC: i32 = 2;
/// Machine-readable stderr marker emitted alongside every deterministic
/// error, mirroring the `anodizer-output <key>=<value>` payload convention.
pub const CLASS_MARKER: &str = "anodizer-error-class: deterministic";
/// Transparent marker wrapper: its presence anywhere in an `anyhow` chain
/// classifies the whole error as deterministic. Display and `source()`
/// forward to the wrapped error, so rendered output (top message + `caused
/// by:` chain) is byte-identical to the unwrapped error.
#[derive(Debug)]
pub struct DeterministicError(anyhow::Error);
impl fmt::Display for DeterministicError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for DeterministicError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
// Deref to the wrapped error's top and continue ITS chain — the
// wrapper's own Display already carries the top message, so
// returning the top itself would print it twice.
self.0.source()
}
}
/// Mark an error as deterministic. The returned error renders identically;
/// only [`is_deterministic`] can tell the difference.
pub fn deterministic(err: anyhow::Error) -> anyhow::Error {
anyhow::Error::new(DeterministicError(err))
}
/// [`deterministic`] for message-shaped errors — the `Result<_, String>`
/// validator idiom (`.map_err(deterministic_msg)?`).
pub fn deterministic_msg<M>(msg: M) -> anyhow::Error
where
M: fmt::Display + fmt::Debug + Send + Sync + 'static,
{
deterministic(anyhow::Error::msg(msg))
}
/// True when any link of the chain is a [`DeterministicError`] marker —
/// context layered on top of a marked error does not hide it.
pub fn is_deterministic(err: &anyhow::Error) -> bool {
err.chain().any(|c| c.is::<DeterministicError>())
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Context as _;
#[test]
fn plain_error_is_not_deterministic() {
let err = anyhow::anyhow!("network timeout");
assert!(!is_deterministic(&err));
}
#[test]
fn wrapped_error_is_deterministic() {
let err = deterministic(anyhow::anyhow!("bad config"));
assert!(is_deterministic(&err));
}
#[test]
fn msg_wrapper_is_deterministic() {
let err = deterministic_msg("unknown publisher 'nmp'".to_string());
assert!(is_deterministic(&err));
}
#[test]
fn context_on_top_still_detected() {
let err: anyhow::Error = Err::<(), _>(deterministic_msg("parse failure"))
.context("failed to load config")
.unwrap_err();
assert!(is_deterministic(&err));
}
#[test]
fn display_and_chain_render_identically_to_unwrapped() {
let unwrapped: anyhow::Error = Err::<(), _>(anyhow::anyhow!("root cause"))
.context("middle")
.context("top")
.unwrap_err();
let wrapped = deterministic(
Err::<(), _>(anyhow::anyhow!("root cause"))
.context("middle")
.context("top")
.unwrap_err(),
);
assert_eq!(wrapped.to_string(), unwrapped.to_string());
let unwrapped_chain: Vec<String> = unwrapped.chain().map(|c| c.to_string()).collect();
let wrapped_chain: Vec<String> = wrapped.chain().map(|c| c.to_string()).collect();
assert_eq!(wrapped_chain, unwrapped_chain);
}
}