#![allow(dead_code)]
use redisctl_core::AuthError;
#[derive(Debug, Clone)]
pub struct StructuredError {
pub code: &'static str,
pub message: String,
pub retryable: bool,
pub exit_code: u8,
}
impl std::fmt::Display for StructuredError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for StructuredError {}
impl StructuredError {
fn new(code: &'static str, exit_code: u8, retryable: bool, message: impl Into<String>) -> Self {
Self {
code,
exit_code,
retryable,
message: message.into(),
}
}
pub fn device_code_expired() -> Self {
Self::new(
"device_code_expired",
3,
true,
"the login code expired before it was approved; run `redisctl cloud auth login` again",
)
}
pub fn auth_denied() -> Self {
Self::new("auth_denied", 2, false, "the login request was denied")
}
pub fn auth_pending() -> Self {
Self::new(
"auth_pending",
3,
true,
"login has not been approved yet; approve it and retry",
)
}
pub fn invalid_endpoint(message: impl Into<String>) -> Self {
Self::new("invalid_endpoint", 2, false, message)
}
pub fn not_authenticated(message: impl Into<String>) -> Self {
Self::new("not_authenticated", 2, false, message)
}
pub fn sm_exchange_failed(message: impl Into<String>) -> Self {
Self::new("sm_exchange_failed", 1, false, message)
}
pub fn keyring_unavailable(message: impl Into<String>) -> Self {
Self::new("keyring_unavailable", 2, false, message)
}
pub fn insufficient_permission(message: impl Into<String>) -> Self {
Self::new("insufficient_permission", 2, false, message)
}
pub fn account_required(message: impl Into<String>) -> Self {
Self::new("account_required", 2, false, message)
}
pub fn capi_disabled(message: impl Into<String>) -> Self {
Self::new("capi_disabled", 2, false, message)
}
pub fn unknown_account(message: impl Into<String>) -> Self {
Self::new("unknown_account", 2, false, message)
}
pub fn migration_required() -> Self {
Self::new(
"migration_required",
2,
false,
"this Redis Cloud account signs in with a password and must be linked to Google or \
GitHub once, in the Redis Cloud console, before the CLI can use it",
)
}
pub fn mfa_required() -> Self {
Self::new(
"mfa_required",
2,
false,
"this account requires multi-factor authentication; run `redisctl cloud auth login` \
in an interactive terminal to enter the code",
)
}
pub fn mfa_invalid_code() -> Self {
Self::new(
"mfa_invalid_code",
2,
false,
"the multi-factor code was not accepted; start login again",
)
}
pub fn mfa_quota_exceeded() -> Self {
Self::new(
"mfa_quota_exceeded",
4,
false,
"too many multi-factor attempts; wait before trying again",
)
}
pub fn invalid_name(message: impl Into<String>) -> Self {
Self::new("invalid_name", 2, false, message)
}
pub fn name_conflict(message: impl Into<String>) -> Self {
Self::new("name_conflict", 2, false, message)
}
pub fn free_db_exists(message: impl Into<String>) -> Self {
Self::new("free_db_exists", 4, false, message)
}
pub fn quota_exceeded(message: impl Into<String>) -> Self {
Self::new("quota_exceeded", 4, false, message)
}
pub fn transient_api_error(message: impl Into<String>) -> Self {
Self::new("transient_api_error", 3, true, message)
}
pub fn rate_limited(message: impl Into<String>) -> Self {
Self::new("rate_limited", 3, true, message)
}
pub fn unknown(message: impl Into<String>) -> Self {
Self::new("unknown", 1, false, message)
}
pub fn envelope(&self) -> serde_json::Value {
serde_json::json!({
"status": "error",
"error": {
"code": self.code,
"message": self.message,
"retryable": self.retryable,
}
})
}
}
impl From<redisctl_core::cloud::quick_database::QuickDatabaseError> for StructuredError {
fn from(err: redisctl_core::cloud::quick_database::QuickDatabaseError) -> Self {
use redisctl_core::cloud::quick_database::QuickDatabaseError as E;
match err {
E::InvalidName(m) => Self::invalid_name(m),
E::NameConflict(m) => Self::name_conflict(m),
E::FreeDbExists(m) => Self::free_db_exists(m),
E::QuotaExceeded(m) => Self::quota_exceeded(m),
E::NotAuthenticated(m) => Self::not_authenticated(m),
E::Transient(m) => Self::transient_api_error(m),
E::RateLimited(m) => Self::rate_limited(m),
E::Other(m) => Self::unknown(m),
}
}
}
impl From<AuthError> for StructuredError {
fn from(err: AuthError) -> Self {
match err {
AuthError::Expired => Self::device_code_expired(),
AuthError::Denied => Self::auth_denied(),
AuthError::Network(e) => Self::transient_api_error(format!(
"network error contacting the identity provider: {e}"
)),
AuthError::Transport(m) => Self::transient_api_error(format!(
"network error contacting the identity provider: {m}"
)),
AuthError::Protocol(msg) => {
Self::sm_exchange_failed(format!("login exchange failed: {msg}"))
}
AuthError::NotAccountOwner { .. } => Self::insufficient_permission(err.to_string()),
AuthError::CapiDisabled => Self::capi_disabled(err.to_string()),
AuthError::AccountRequired(_) => Self::account_required(err.to_string()),
AuthError::UnknownAccount { .. } => Self::unknown_account(err.to_string()),
AuthError::MigrationRequired => Self::migration_required(),
AuthError::MfaRequired { .. } => Self::mfa_required(),
AuthError::MfaInvalidCode => Self::mfa_invalid_code(),
AuthError::MfaQuotaExceeded => Self::mfa_quota_exceeded(),
other => Self::unknown(other.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inventory_is_consistent() {
let all = [
StructuredError::device_code_expired(),
StructuredError::auth_denied(),
StructuredError::auth_pending(),
StructuredError::not_authenticated("x"),
StructuredError::sm_exchange_failed("x"),
StructuredError::keyring_unavailable("x"),
StructuredError::insufficient_permission("x"),
StructuredError::capi_disabled("x"),
StructuredError::account_required("x"),
StructuredError::unknown_account("x"),
StructuredError::migration_required(),
StructuredError::mfa_required(),
StructuredError::mfa_invalid_code(),
StructuredError::mfa_quota_exceeded(),
StructuredError::invalid_name("x"),
StructuredError::name_conflict("x"),
StructuredError::free_db_exists("x"),
StructuredError::quota_exceeded("x"),
StructuredError::transient_api_error("x"),
StructuredError::rate_limited("x"),
StructuredError::unknown("x"),
];
for e in &all {
assert!(
(1..=4).contains(&e.exit_code),
"{}: exit_code {} out of range",
e.code,
e.exit_code
);
assert_eq!(
e.retryable,
e.exit_code == 3,
"{}: retryable must align with exit 3",
e.code
);
}
}
#[test]
fn auth_error_mapping() {
assert_eq!(
StructuredError::from(AuthError::Expired).code,
"device_code_expired"
);
assert_eq!(StructuredError::from(AuthError::Denied).code, "auth_denied");
assert_eq!(
StructuredError::from(AuthError::Protocol("boom".into())).code,
"sm_exchange_failed"
);
let owner = StructuredError::from(AuthError::NotAccountOwner {
allowed_roles: "the owner role".to_string(),
});
assert_eq!(owner.code, "insufficient_permission");
assert_eq!(owner.exit_code, 2);
assert!(owner.message.contains("the owner role"));
assert_eq!(
owner.message,
AuthError::NotAccountOwner {
allowed_roles: "the owner role".to_string()
}
.to_string()
);
let disabled = StructuredError::from(AuthError::CapiDisabled);
assert_eq!(disabled.code, "capi_disabled");
assert_eq!(disabled.exit_code, 2);
assert!(!disabled.retryable);
let mig = StructuredError::from(AuthError::MigrationRequired);
assert_eq!(mig.code, "migration_required");
assert_eq!(mig.exit_code, 2);
assert!(!mig.retryable);
let mfa = StructuredError::from(AuthError::MfaRequired { factors: vec![] });
assert_eq!(mfa.code, "mfa_required");
assert_eq!(mfa.exit_code, 2);
assert!(!mfa.retryable);
assert_eq!(
StructuredError::from(AuthError::MfaInvalidCode).code,
"mfa_invalid_code"
);
let transport = StructuredError::from(AuthError::Transport("connection refused".into()));
assert_eq!(transport.code, "transient_api_error");
assert_eq!(transport.exit_code, 3);
assert!(transport.retryable);
let protocol = StructuredError::from(AuthError::Protocol("invalid_grant".into()));
assert_eq!(protocol.code, "sm_exchange_failed");
assert!(!protocol.retryable);
let quota = StructuredError::from(AuthError::MfaQuotaExceeded);
assert_eq!(quota.code, "mfa_quota_exceeded");
assert_eq!(quota.exit_code, 4);
}
#[test]
fn quick_database_error_mapping() {
use redisctl_core::cloud::quick_database::QuickDatabaseError as E;
let cases = [
(E::InvalidName("x".into()), "invalid_name", 2u8),
(E::NameConflict("x".into()), "name_conflict", 2),
(E::FreeDbExists("x".into()), "free_db_exists", 4),
(E::QuotaExceeded("x".into()), "quota_exceeded", 4),
(E::NotAuthenticated("x".into()), "not_authenticated", 2),
(E::Transient("x".into()), "transient_api_error", 3),
(E::RateLimited("x".into()), "rate_limited", 3),
(E::Other("x".into()), "unknown", 1),
];
for (err, code, exit) in cases {
let se = StructuredError::from(err);
assert_eq!(se.code, code);
assert_eq!(se.exit_code, exit, "{code}: exit code");
assert_eq!(
se.retryable,
exit == 3,
"{code}: retryable aligns with exit 3"
);
}
}
#[test]
fn envelope_shape() {
let e = StructuredError::invalid_name("bad name");
let env = e.envelope();
assert_eq!(env["status"], "error");
assert_eq!(env["error"]["code"], "invalid_name");
assert_eq!(env["error"]["retryable"], false);
assert_eq!(env["error"]["message"], "bad name");
}
}