use rustlavel_core::{Error, Json};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VaultError {
BadRequest(String),
PermissionDenied(String),
NotFound { path: String },
Stale(String),
Standby(String),
Sealed(String),
Unexpected { status: u16, message: String },
Transport(String),
Malformed { path: String, message: String },
}
impl VaultError {
pub fn from_response(status: u16, path: &str, body: &str) -> VaultError {
let message = messages(body);
match status {
400 => VaultError::BadRequest(message),
401 | 403 => VaultError::PermissionDenied(message),
404 => VaultError::NotFound { path: path.to_string() },
412 => VaultError::Stale(message),
429 => VaultError::Standby(message),
501 | 503 => VaultError::Sealed(message),
other => VaultError::Unexpected { status: other, message },
}
}
pub fn is_retryable(&self) -> bool {
matches!(self, VaultError::Stale(_) | VaultError::Standby(_) | VaultError::Transport(_))
}
pub fn needs_new_token(&self) -> bool {
matches!(self, VaultError::PermissionDenied(_))
}
pub fn is_not_found(&self) -> bool {
matches!(self, VaultError::NotFound { .. })
}
}
fn messages(body: &str) -> String {
let parsed = Json::parse(body).ok();
let errors = parsed
.as_ref()
.and_then(|json| json.get("errors"))
.and_then(Json::as_array)
.map(|items| {
items.iter().filter_map(Json::as_str).collect::<Vec<_>>().join("; ")
})
.filter(|joined| !joined.is_empty());
match errors {
Some(errors) => errors,
None => {
let trimmed = body.trim();
if trimmed.is_empty() {
"the server sent no explanation".to_string()
} else if trimmed.len() > 300 {
format!("{}…", &trimmed[..300])
} else {
trimmed.to_string()
}
}
}
}
impl std::fmt::Display for VaultError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VaultError::BadRequest(message) => write!(f, "the secret store rejected the request: {message}"),
VaultError::PermissionDenied(message) => write!(
f,
"the secret store refused: {message}. The token is missing, expired, or has no \
policy granting this path — Vault answers the same way for all three."
),
VaultError::NotFound { path } => {
write!(f, "nothing is stored at `{path}`")
}
VaultError::Stale(message) => write!(
f,
"a standby node is behind and cannot serve this yet: {message}"
),
VaultError::Standby(message) => {
write!(f, "this node is a standby and will not serve the request: {message}")
}
VaultError::Sealed(message) => write!(
f,
"the secret store is sealed or unavailable: {message}. It has to be unsealed \
before anything here will work; retrying will not help."
),
VaultError::Unexpected { status, message } => {
write!(f, "the secret store answered {status}: {message}")
}
VaultError::Transport(message) => {
write!(f, "could not reach the secret store: {message}")
}
VaultError::Malformed { path, message } => {
write!(f, "the reply from `{path}` was not what this driver expected: {message}")
}
}
}
}
impl std::error::Error for VaultError {}
impl From<VaultError> for Error {
fn from(error: VaultError) -> Error {
Error::msg(error.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_each_status_to_what_the_caller_should_do_about_it() {
let body = r#"{"errors":["permission denied"]}"#;
assert!(matches!(
VaultError::from_response(403, "secret/data/x", body),
VaultError::PermissionDenied(_)
));
assert!(matches!(
VaultError::from_response(404, "secret/data/x", "{}"),
VaultError::NotFound { .. }
));
assert!(matches!(
VaultError::from_response(503, "sys/health", "{}"),
VaultError::Sealed(_)
));
assert!(matches!(
VaultError::from_response(418, "secret/data/x", "{}"),
VaultError::Unexpected { status: 418, .. }
));
}
#[test]
fn a_missing_token_and_a_forbidden_one_look_the_same_because_they_are() {
assert_eq!(
VaultError::from_response(401, "p", r#"{"errors":["x"]}"#),
VaultError::from_response(403, "p", r#"{"errors":["x"]}"#),
);
}
#[test]
fn a_sealed_store_is_not_retried() {
assert!(!VaultError::Sealed("sealed".into()).is_retryable());
assert!(VaultError::Standby("standby".into()).is_retryable());
assert!(VaultError::Stale("behind".into()).is_retryable());
assert!(VaultError::Transport("refused".into()).is_retryable());
assert!(!VaultError::BadRequest("bad".into()).is_retryable());
}
#[test]
fn only_a_refusal_suggests_getting_a_new_token() {
assert!(VaultError::PermissionDenied("x".into()).needs_new_token());
assert!(!VaultError::NotFound { path: "p".into() }.needs_new_token());
}
#[test]
fn reads_the_error_list_vault_actually_sends() {
let error = VaultError::from_response(400, "p", r#"{"errors":["one","two"]}"#);
assert!(error.to_string().contains("one; two"), "got {error}");
}
#[test]
fn a_body_that_is_not_json_is_shown_rather_than_swallowed() {
let error = VaultError::from_response(502, "p", "<html>Bad Gateway</html>");
assert!(error.to_string().contains("Bad Gateway"), "got {error}");
}
#[test]
fn an_empty_body_says_so_instead_of_pretending() {
let error = VaultError::from_response(500, "p", "");
assert!(error.to_string().contains("no explanation"), "got {error}");
}
#[test]
fn a_huge_body_is_truncated_so_it_cannot_flood_a_log() {
let error = VaultError::from_response(500, "p", &"x".repeat(5000));
assert!(error.to_string().len() < 500, "an error must not be a log flood");
}
#[test]
fn a_not_found_names_the_path_because_that_is_the_useful_part() {
let error = VaultError::from_response(404, "secret/data/missing", "{}");
assert!(error.to_string().contains("secret/data/missing"));
}
#[test]
fn an_errors_field_that_is_empty_falls_back_to_the_body() {
let error = VaultError::from_response(404, "p", r#"{"errors":[]}"#);
assert!(!error.to_string().is_empty());
}
}