Skip to main content

rustlavel_vault/
error.rs

1//! What a secret store says when it says no.
2//!
3//! Vault and OpenBao return the same body for every failure — `{"errors": [...]}`
4//! — and put the meaning in the status code. Distinguishing those codes is not
5//! pedantry: "the token expired", "this token may not read that path" and "the
6//! server is sealed" call for three completely different responses from the
7//! caller, and all three are a bare 4xx if you do not look.
8
9use rustlavel_core::{Error, Json};
10
11/// A failure from the secret store.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum VaultError {
14    /// 400 — the request itself was wrong.
15    BadRequest(String),
16    /// 403 — no token, an expired one, or one without the policy for this path.
17    ///
18    /// Vault does not distinguish "expired" from "not allowed", so neither can
19    /// this. The message says so rather than guessing.
20    PermissionDenied(String),
21    /// 404 — no such path, or nothing stored at it.
22    ///
23    /// Worth its own variant because it is usually *not* an error: reading a
24    /// secret that may not be there yet is an ordinary thing to do.
25    NotFound { path: String },
26    /// 412 — a standby node has not caught up with the value being asked for.
27    /// Retrying against the active node is the fix, and it usually works.
28    Stale(String),
29    /// 429 — a standby that will not serve this request at all.
30    Standby(String),
31    /// 503 — the store is sealed or unavailable. Nothing the caller does helps;
32    /// somebody has to unseal it.
33    Sealed(String),
34    /// Anything else, including a body that did not parse.
35    Unexpected { status: u16, message: String },
36    /// The request never reached the server.
37    Transport(String),
38    /// The store answered successfully, with something this driver cannot read.
39    ///
40    /// Distinct from every variant above, which are the store saying no. This
41    /// one is the store saying yes and handing over a shape we did not expect —
42    /// a KV version 1 mount read as version 2, or credentials with no password
43    /// in them. Retrying cannot help, and treating it as a plain failure would
44    /// hide the one detail that explains it: which path, and what was wrong.
45    Malformed { path: String, message: String },
46}
47
48impl VaultError {
49    /// Build from a status code and whatever body came with it.
50    pub fn from_response(status: u16, path: &str, body: &str) -> VaultError {
51        let message = messages(body);
52
53        match status {
54            400 => VaultError::BadRequest(message),
55            // 401 is not in Vault's vocabulary — it answers 403 for a missing
56            // token as well as a forbidden one — but a proxy in front may
57            // produce it, and treating it as anything else would be confusing.
58            401 | 403 => VaultError::PermissionDenied(message),
59            404 => VaultError::NotFound { path: path.to_string() },
60            412 => VaultError::Stale(message),
61            429 => VaultError::Standby(message),
62            501 | 503 => VaultError::Sealed(message),
63            other => VaultError::Unexpected { status: other, message },
64        }
65    }
66
67    /// Whether trying again, unchanged, could plausibly work.
68    ///
69    /// A sealed store is deliberately *not* retryable: unsealing is a human
70    /// action that takes minutes at best, and hammering the endpoint until it
71    /// happens only buries the log line that would have explained the outage.
72    pub fn is_retryable(&self) -> bool {
73        matches!(self, VaultError::Stale(_) | VaultError::Standby(_) | VaultError::Transport(_))
74    }
75
76    /// Whether re-authenticating and trying again could work.
77    pub fn needs_new_token(&self) -> bool {
78        matches!(self, VaultError::PermissionDenied(_))
79    }
80
81    pub fn is_not_found(&self) -> bool {
82        matches!(self, VaultError::NotFound { .. })
83    }
84}
85
86/// Pull the messages out of `{"errors": ["...", "..."]}`.
87///
88/// Falls back to the raw body, trimmed: an HTML error page from a proxy in
89/// front of Vault is not the shape we expect, and showing it is far more useful
90/// than reporting "unknown error".
91fn messages(body: &str) -> String {
92    let parsed = Json::parse(body).ok();
93    let errors = parsed
94        .as_ref()
95        .and_then(|json| json.get("errors"))
96        .and_then(Json::as_array)
97        .map(|items| {
98            items.iter().filter_map(Json::as_str).collect::<Vec<_>>().join("; ")
99        })
100        .filter(|joined| !joined.is_empty());
101
102    match errors {
103        Some(errors) => errors,
104        None => {
105            let trimmed = body.trim();
106            if trimmed.is_empty() {
107                "the server sent no explanation".to_string()
108            } else if trimmed.len() > 300 {
109                format!("{}…", &trimmed[..300])
110            } else {
111                trimmed.to_string()
112            }
113        }
114    }
115}
116
117impl std::fmt::Display for VaultError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            VaultError::BadRequest(message) => write!(f, "the secret store rejected the request: {message}"),
121            VaultError::PermissionDenied(message) => write!(
122                f,
123                "the secret store refused: {message}. The token is missing, expired, or has no \
124                 policy granting this path — Vault answers the same way for all three."
125            ),
126            VaultError::NotFound { path } => {
127                write!(f, "nothing is stored at `{path}`")
128            }
129            VaultError::Stale(message) => write!(
130                f,
131                "a standby node is behind and cannot serve this yet: {message}"
132            ),
133            VaultError::Standby(message) => {
134                write!(f, "this node is a standby and will not serve the request: {message}")
135            }
136            VaultError::Sealed(message) => write!(
137                f,
138                "the secret store is sealed or unavailable: {message}. It has to be unsealed \
139                 before anything here will work; retrying will not help."
140            ),
141            VaultError::Unexpected { status, message } => {
142                write!(f, "the secret store answered {status}: {message}")
143            }
144            VaultError::Transport(message) => {
145                write!(f, "could not reach the secret store: {message}")
146            }
147            VaultError::Malformed { path, message } => {
148                write!(f, "the reply from `{path}` was not what this driver expected: {message}")
149            }
150        }
151    }
152}
153
154impl std::error::Error for VaultError {}
155
156impl From<VaultError> for Error {
157    fn from(error: VaultError) -> Error {
158        Error::msg(error.to_string())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn maps_each_status_to_what_the_caller_should_do_about_it() {
168        let body = r#"{"errors":["permission denied"]}"#;
169
170        assert!(matches!(
171            VaultError::from_response(403, "secret/data/x", body),
172            VaultError::PermissionDenied(_)
173        ));
174        assert!(matches!(
175            VaultError::from_response(404, "secret/data/x", "{}"),
176            VaultError::NotFound { .. }
177        ));
178        assert!(matches!(
179            VaultError::from_response(503, "sys/health", "{}"),
180            VaultError::Sealed(_)
181        ));
182        assert!(matches!(
183            VaultError::from_response(418, "secret/data/x", "{}"),
184            VaultError::Unexpected { status: 418, .. }
185        ));
186    }
187
188    #[test]
189    fn a_missing_token_and_a_forbidden_one_look_the_same_because_they_are() {
190        // Vault genuinely does not distinguish them. Inventing a distinction
191        // here would mean lying about which one happened.
192        assert_eq!(
193            VaultError::from_response(401, "p", r#"{"errors":["x"]}"#),
194            VaultError::from_response(403, "p", r#"{"errors":["x"]}"#),
195        );
196    }
197
198    #[test]
199    fn a_sealed_store_is_not_retried() {
200        // Retrying cannot unseal it, and the retries would bury the one log
201        // line that explains the outage.
202        assert!(!VaultError::Sealed("sealed".into()).is_retryable());
203        assert!(VaultError::Standby("standby".into()).is_retryable());
204        assert!(VaultError::Stale("behind".into()).is_retryable());
205        assert!(VaultError::Transport("refused".into()).is_retryable());
206        assert!(!VaultError::BadRequest("bad".into()).is_retryable());
207    }
208
209    #[test]
210    fn only_a_refusal_suggests_getting_a_new_token() {
211        assert!(VaultError::PermissionDenied("x".into()).needs_new_token());
212        assert!(!VaultError::NotFound { path: "p".into() }.needs_new_token());
213    }
214
215    #[test]
216    fn reads_the_error_list_vault_actually_sends() {
217        let error = VaultError::from_response(400, "p", r#"{"errors":["one","two"]}"#);
218        assert!(error.to_string().contains("one; two"), "got {error}");
219    }
220
221    #[test]
222    fn a_body_that_is_not_json_is_shown_rather_than_swallowed() {
223        // A proxy in front of Vault answering with HTML is exactly when the
224        // body matters most.
225        let error = VaultError::from_response(502, "p", "<html>Bad Gateway</html>");
226        assert!(error.to_string().contains("Bad Gateway"), "got {error}");
227    }
228
229    #[test]
230    fn an_empty_body_says_so_instead_of_pretending() {
231        let error = VaultError::from_response(500, "p", "");
232        assert!(error.to_string().contains("no explanation"), "got {error}");
233    }
234
235    #[test]
236    fn a_huge_body_is_truncated_so_it_cannot_flood_a_log() {
237        let error = VaultError::from_response(500, "p", &"x".repeat(5000));
238        assert!(error.to_string().len() < 500, "an error must not be a log flood");
239    }
240
241    #[test]
242    fn a_not_found_names_the_path_because_that_is_the_useful_part() {
243        let error = VaultError::from_response(404, "secret/data/missing", "{}");
244        assert!(error.to_string().contains("secret/data/missing"));
245    }
246
247    #[test]
248    fn an_errors_field_that_is_empty_falls_back_to_the_body() {
249        // Vault sends `{"errors":[]}` for some 404s, which would otherwise
250        // render as an error with no text at all.
251        let error = VaultError::from_response(404, "p", r#"{"errors":[]}"#);
252        assert!(!error.to_string().is_empty());
253    }
254}