use std::io;
use std::time::Duration;
use thiserror::Error;
use super::dns::DnsProviderError;
pub const ACME_RETRY_MAX: usize = 3;
pub const ACME_RETRY_BACKOFF: Duration = Duration::from_secs(1);
#[derive(Debug, Error)]
pub enum AcmeError {
#[error("ACME account not initialized - call init_account() first")]
NoAccount,
#[error("Failed to create ACME account: {0}")]
AccountCreation(String),
#[error("Failed to create certificate order: {0}")]
OrderCreation(String),
#[error("Challenge validation failed for domain '{domain}': {message}")]
ChallengeValidation { domain: String, message: String },
#[error("Failed to finalize certificate: {0}")]
Finalization(String),
#[error("Storage error: {0}")]
Storage(#[from] StorageError),
#[error("ACME protocol error: {0}")]
Protocol(String),
#[error("Operation timed out: {0}")]
Timeout(String),
#[error("No HTTP-01 challenge available for domain '{0}'")]
NoHttp01Challenge(String),
#[error("No DNS-01 challenge available for domain '{0}'")]
NoDns01Challenge(String),
#[error("DNS-01 challenge requires a DNS provider configuration")]
NoDnsProvider,
#[error("DNS provider error: {0}")]
DnsProvider(#[from] DnsProviderError),
#[error("DNS propagation timeout for record '{record}' after {elapsed:?}")]
PropagationTimeout { record: String, elapsed: Duration },
#[error("Wildcard domain '{domain}' requires DNS-01 challenge type")]
WildcardRequiresDns01 { domain: String },
#[error("Failed to parse certificate: {0}")]
CertificateParse(String),
}
#[derive(Debug, Error)]
pub enum StorageError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Serialization error: {0}")]
Serialization(String),
#[error("Storage directory not writable: {path}")]
NotWritable { path: String },
#[error("Certificate not found for domain: {domain}")]
CertificateNotFound { domain: String },
#[error("Invalid storage structure: {0}")]
InvalidStructure(String),
}
impl From<serde_json::Error> for StorageError {
fn from(e: serde_json::Error) -> Self {
StorageError::Serialization(e.to_string())
}
}
impl From<instant_acme::Error> for AcmeError {
fn from(e: instant_acme::Error) -> Self {
AcmeError::Protocol(e.to_string())
}
}
pub fn is_retryable_acme_error(e: &AcmeError) -> bool {
if matches!(
e,
AcmeError::Storage(_)
| AcmeError::NoAccount
| AcmeError::NoHttp01Challenge(_)
| AcmeError::NoDns01Challenge(_)
| AcmeError::NoDnsProvider
| AcmeError::WildcardRequiresDns01 { .. }
| AcmeError::CertificateParse(_)
| AcmeError::Timeout(_)
| AcmeError::PropagationTimeout { .. }
) {
return false;
}
if matches!(
e,
AcmeError::DnsProvider(
DnsProviderError::Timeout { .. }
| DnsProviderError::RateLimited { .. }
| DnsProviderError::Authentication(_)
| DnsProviderError::ZoneNotFound { .. }
| DnsProviderError::Configuration(_)
| DnsProviderError::Credentials(_)
| DnsProviderError::UnsupportedDomain { .. }
)
) {
return false;
}
let msg = e.to_string().to_lowercase();
msg.contains("client error (connect)")
|| msg.contains("unexpected eof")
|| msg.contains("connection reset")
|| msg.contains("connection closed")
|| msg.contains("connection aborted")
|| msg.contains("tls connect error")
|| msg.contains("timed out")
|| msg.contains("transport error")
|| msg.contains("hyper::")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_retryable_connect_and_eof() {
let e = AcmeError::AccountCreation("client error (Connect)".to_string());
assert!(is_retryable_acme_error(&e));
let e = AcmeError::Protocol("unexpected eof while reading".to_string());
assert!(is_retryable_acme_error(&e));
let e = AcmeError::AccountCreation("TLS connect error".to_string());
assert!(is_retryable_acme_error(&e));
}
#[test]
fn test_retryable_transport_variants() {
let e = AcmeError::OrderCreation("connection reset by peer".to_string());
assert!(is_retryable_acme_error(&e));
let e = AcmeError::OrderCreation("transport error: closed".to_string());
assert!(is_retryable_acme_error(&e));
let e = AcmeError::Finalization("hyper::Error(ChannelClosed)".to_string());
assert!(is_retryable_acme_error(&e));
}
#[test]
fn test_non_retryable_timeouts_and_storage() {
let e = AcmeError::Timeout("timed out waiting for order".to_string());
assert!(!is_retryable_acme_error(&e));
let e = AcmeError::PropagationTimeout {
record: "_acme-challenge.example.com".to_string(),
elapsed: Duration::from_secs(120),
};
assert!(!is_retryable_acme_error(&e));
let e = AcmeError::Storage(StorageError::InvalidStructure("x".to_string()));
assert!(!is_retryable_acme_error(&e));
let e = AcmeError::AccountCreation("401 Unauthorized".to_string());
assert!(!is_retryable_acme_error(&e));
}
#[test]
fn test_non_retryable_dns_provider_variants() {
let e = AcmeError::DnsProvider(DnsProviderError::Timeout { elapsed_secs: 120 });
assert!(!is_retryable_acme_error(&e));
assert!(e.to_string().to_lowercase().contains("timed out"));
let e = AcmeError::DnsProvider(DnsProviderError::Authentication("bad token".to_string()));
assert!(!is_retryable_acme_error(&e));
let e = AcmeError::DnsProvider(DnsProviderError::ZoneNotFound {
domain: "example.com".to_string(),
});
assert!(!is_retryable_acme_error(&e));
let e = AcmeError::DnsProvider(DnsProviderError::RateLimited {
retry_after_secs: 60,
});
assert!(!is_retryable_acme_error(&e));
}
}