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}