Skip to main content

dynamic_config/
error.rs

1//! The crate's single error type.
2//!
3//! No backend error type is ever exposed: a `figment::Error` or a
4//! `serde_json::Error` is translated into an [`Error`] at the boundary, so
5//! switching backends never changes a caller's signatures.
6
7use std::fmt;
8use std::path::PathBuf;
9
10/// Broad category of a configuration failure.
11///
12/// Matching on this is enough to decide how to react; the human-readable
13/// detail lives in the [`Display`](fmt::Display) output.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum ErrorKind {
17    /// A configured file exists but could not be read.
18    Io,
19    /// A file was read but is not valid in its format.
20    Parse,
21    /// A required value was not supplied by any source.
22    Missing,
23    /// A value was supplied but cannot become the requested type.
24    Type,
25    /// An environment variable could not be interpreted.
26    Env,
27    /// Every value parsed, but the configuration as a whole was rejected.
28    Invalid,
29    /// A remote store could not be read.
30    Remote,
31    /// A credential was rejected, or could not be obtained.
32    ///
33    /// Distinct from [`Remote`](Self::Remote), which is the store being
34    /// unreachable: **that one may fix itself by waiting; this one will not.**
35    /// A watch loop can back off on the first and stop on the second.
36    ///
37    /// The credential itself is never part of the message.
38    Auth,
39    /// The store answered, and the document is not there.
40    ///
41    /// Distinct from [`Remote`](Self::Remote) — which is the store being
42    /// unreachable — and from [`Missing`](Self::Missing), which is a key
43    /// absent from a document that *was* read. This one is the document
44    /// itself: a key deleted, a path that never existed, a bucket object
45    /// removed.
46    ///
47    /// The difference matters because the three deserve different answers.
48    /// Waiting fixes an unreachable store. Waiting does not bring back a
49    /// deleted secret, and treating it as an outage is how a stale document
50    /// gets served for a week with every health check reporting fine.
51    Absent,
52    /// An encrypted file could not be decrypted.
53    Decrypt,
54    /// The active backend failed for a reason of its own.
55    Backend,
56}
57
58impl ErrorKind {
59    /// A short, stable label. Useful for metrics and log fields.
60    #[must_use]
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Io => "io",
64            Self::Parse => "parse",
65            Self::Missing => "missing",
66            Self::Type => "type",
67            Self::Env => "env",
68            Self::Invalid => "invalid",
69            Self::Remote => "remote",
70            Self::Auth => "auth",
71            Self::Absent => "absent",
72            Self::Decrypt => "decrypt",
73            Self::Backend => "backend",
74        }
75    }
76}
77
78/// Where a value came from.
79///
80/// Attached to errors so the first question of every configuration bug —
81/// *which source set this?* — is answered by the message itself.
82#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum Origin {
85    /// A file on disk.
86    File(PathBuf),
87    /// An environment variable, named in full.
88    Env(String),
89    /// An in-memory source supplied by the caller.
90    Inline,
91    /// A remote store, as it described itself.
92    Remote(String),
93    /// A value set from code: `"default"` or `"override"`.
94    Runtime(&'static str),
95    /// Provenance could not be determined.
96    Unknown,
97}
98
99impl fmt::Display for Origin {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            Self::File(path) => write!(f, "in {}", path.display()),
103            Self::Env(name) => write!(f, "from {name}"),
104            Self::Inline => f.write_str("in an inline source"),
105            Self::Remote(store) => write!(f, "from {store}"),
106            Self::Runtime(layer) => write!(f, "set as {layer}"),
107            Self::Unknown => f.write_str("origin unknown"),
108        }
109    }
110}
111
112/// A configuration error.
113///
114/// Boxed internally so that `Result<T, Error>` stays small on the hot path —
115/// `load` is called on every reload, and most calls succeed.
116pub struct Error {
117    inner: Box<Inner>,
118}
119
120struct Inner {
121    kind: ErrorKind,
122    /// Key path from the config root, outermost segment first.
123    path: Vec<String>,
124    origin: Origin,
125    message: String,
126}
127
128impl Error {
129    /// Builds an error with no path and no known origin.
130    pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
131        Self {
132            inner: Box::new(Inner {
133                kind,
134                path: Vec::new(),
135                origin: Origin::Unknown,
136                message: message.into(),
137            }),
138        }
139    }
140
141    /// A configuration that parsed but failed its own validation.
142    ///
143    /// Called by the code `#[dynamic_config(.., validate)]` generates, from
144    /// whatever `validate` resolves to at the call site.
145    pub fn invalid<E: fmt::Display>(error: E) -> Self {
146        Self::new(ErrorKind::Invalid, error.to_string())
147    }
148
149    /// Maps a validation result into a load failure.
150    ///
151    /// Takes the whole `Result` rather than the error, so the generated code is
152    /// one expression regardless of what `validate` returns.
153    ///
154    /// # Errors
155    ///
156    /// If `outcome` is `Err`.
157    pub fn ok_or_invalid<T, E: fmt::Display>(outcome: Result<T, E>) -> Result<(), Self> {
158        outcome.map(|_| ()).map_err(Self::invalid)
159    }
160
161    /// A remote store that could not be read.
162    ///
163    /// For implementors of [`RemoteSource`](crate::RemoteSource), so a network
164    /// failure is categorised the same way whichever store it came from.
165    pub fn remote<E: fmt::Display>(error: E) -> Self {
166        Self::new(ErrorKind::Remote, error.to_string())
167    }
168
169    /// A document a remote store does not have.
170    ///
171    /// Also for [`RemoteSource`](crate::RemoteSource) implementors, and the
172    /// third line in the same family: [`remote`](Self::remote) is *the store
173    /// did not answer*, [`auth`](Self::auth) is *the store refused you*, and
174    /// this is *the store answered, and there is nothing at that path*.
175    ///
176    /// Worth telling apart because the three deserve different reactions. An
177    /// unreachable store is waited out. A refused credential stops the loop.
178    /// A deleted document does neither: waiting will not bring it back, and
179    /// stopping loses the configuration that is still serving — so a caller
180    /// has a policy to apply, and cannot apply one it was never told about.
181    ///
182    /// A 404 belongs here only where the store means one thing by it. Where
183    /// a 404 is also how a store spells "you may not read that", prefer
184    /// [`auth`](Self::auth) and say so — the config server does exactly that.
185    pub fn absent<E: fmt::Display>(error: E) -> Self {
186        Self::new(ErrorKind::Absent, error.to_string())
187    }
188
189    /// A credential a remote store refused, or one that could not be obtained.
190    ///
191    /// Also for [`RemoteSource`](crate::RemoteSource) implementors, and the
192    /// line between this and [`remote`](Self::remote) is the one worth getting
193    /// right: this is for what waiting cannot cure — a 401 or a 403, a token
194    /// that expired and could not be replaced. A network failure *while*
195    /// fetching a token is [`remote`](Self::remote), because the store may yet
196    /// answer; a credential file that is not there is an
197    /// [`Io`](ErrorKind::Io) problem, and one that is there but malformed is a
198    /// [`Parse`](ErrorKind::Parse) problem.
199    ///
200    /// Where a store cannot tell its own 403 from a proxy's, prefer
201    /// [`remote`](Self::remote): a wrong `Auth` stops a watch loop that would
202    /// have recovered.
203    ///
204    /// `error` must not name the credential. It reaches logs.
205    pub fn auth<E: fmt::Display>(error: E) -> Self {
206        Self::new(ErrorKind::Auth, error.to_string())
207    }
208
209    /// A failure decrypting an encrypted config file.
210    ///
211    /// For [`Decryptor`](crate::Decryptor) implementations, so a scheme this
212    /// crate has never heard of still reports through the same category.
213    pub fn decrypt<E: fmt::Display>(error: E) -> Self {
214        Self::new(ErrorKind::Decrypt, error.to_string())
215    }
216
217    /// A path whose extension names no format this build can write.
218    #[must_use]
219    pub fn unsupported(path: &std::path::Path) -> Self {
220        Self::new(
221            ErrorKind::Backend,
222            "the extension names no supported format; expected `.json`, `.toml`, \
223             `.yaml` or `.yml`",
224        )
225        .with_origin(Origin::File(path.to_owned()))
226    }
227
228    /// The error's category.
229    #[must_use]
230    pub fn kind(&self) -> ErrorKind {
231        self.inner.kind
232    }
233
234    /// The dotted key path this error occurred at, empty at the root.
235    #[must_use]
236    pub fn path(&self) -> String {
237        self.inner.path.join(".")
238    }
239
240    /// Where the offending value came from.
241    #[must_use]
242    pub fn origin(&self) -> &Origin {
243        &self.inner.origin
244    }
245
246    /// The detail message, without the path or origin decoration.
247    #[must_use]
248    pub fn message(&self) -> &str {
249        &self.inner.message
250    }
251
252    /// Pushes a key onto the front of the path.
253    ///
254    /// Deserialization unwinds from the innermost field outwards, so each
255    /// enclosing map prepends its own key and the path assembles itself in the
256    /// right order without any of them knowing the full path.
257    pub(crate) fn prepend_key(mut self, key: impl Into<String>) -> Self {
258        self.inner.path.insert(0, key.into());
259        self
260    }
261
262    /// Records provenance, unless something more specific is already known.
263    pub(crate) fn with_origin(mut self, origin: Origin) -> Self {
264        if self.inner.origin == Origin::Unknown {
265            self.inner.origin = origin;
266        }
267        self
268    }
269}
270
271impl fmt::Display for Error {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        if self.inner.path.is_empty() {
274            f.write_str(&self.inner.message)?;
275        } else {
276            write!(f, "{}: {}", self.path(), self.inner.message)?;
277        }
278
279        if self.inner.origin != Origin::Unknown {
280            write!(f, " ({})", self.inner.origin)?;
281        }
282
283        Ok(())
284    }
285}
286
287impl fmt::Debug for Error {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.debug_struct("Error")
290            .field("kind", &self.inner.kind)
291            .field("path", &self.path())
292            .field("origin", &self.inner.origin)
293            .field("message", &self.inner.message)
294            .finish()
295    }
296}
297
298impl std::error::Error for Error {}
299
300impl serde::de::Error for Error {
301    fn custom<T: fmt::Display>(msg: T) -> Self {
302        Self::new(ErrorKind::Type, msg.to_string())
303    }
304
305    fn missing_field(field: &'static str) -> Self {
306        Self::new(ErrorKind::Missing, "missing value").prepend_key(field)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use serde::de::Error as _;
314
315    #[test]
316    fn prepend_key_assembles_the_path_outermost_first() {
317        let error = Error::new(ErrorKind::Type, "boom")
318            .prepend_key("max")
319            .prepend_key("pool")
320            .prepend_key("db");
321
322        assert_eq!(error.path(), "db.pool.max");
323    }
324
325    #[test]
326    fn display_includes_the_path_and_the_origin() {
327        let error = Error::new(ErrorKind::Type, "invalid type")
328            .prepend_key("port")
329            .with_origin(Origin::Env("APP_DB_PORT".to_owned()));
330
331        assert_eq!(error.to_string(), "port: invalid type (from APP_DB_PORT)");
332    }
333
334    #[test]
335    fn display_omits_both_decorations_when_they_are_absent() {
336        let error = Error::new(ErrorKind::Parse, "unexpected end of input");
337
338        assert_eq!(error.to_string(), "unexpected end of input");
339    }
340
341    #[test]
342    fn the_first_origin_recorded_wins() {
343        let error = Error::new(ErrorKind::Type, "boom")
344            .with_origin(Origin::Inline)
345            .with_origin(Origin::Env("APP_X".to_owned()));
346
347        assert_eq!(error.origin(), &Origin::Inline);
348    }
349
350    /// The whole value of the variant is that it is *not* `Remote`: a caller
351    /// backs off on one and stops on the other, so they must never collapse
352    /// into each other.
353    #[test]
354    fn a_refused_credential_is_its_own_kind_rather_than_a_flavour_of_remote() {
355        let refused = Error::auth("the store refused the credential");
356        let unreachable = Error::remote("the store is unreachable");
357
358        assert_eq!(refused.kind(), ErrorKind::Auth);
359        assert_eq!(refused.kind().as_str(), "auth");
360        assert_ne!(refused.kind(), unreachable.kind());
361        assert_eq!(refused.to_string(), "the store refused the credential");
362    }
363
364    #[test]
365    fn a_missing_field_carries_its_name_and_kind() {
366        let error = Error::missing_field("host");
367
368        assert_eq!(error.kind(), ErrorKind::Missing);
369        assert_eq!(error.path(), "host");
370    }
371}