use thiserror::Error;
#[derive(Debug, Error)]
pub enum CleanLibraryError {
#[error("config error: {0}")]
Config(#[from] crate::config::ConfigError),
#[error("policy deny [{reason_code}]: {message}")]
PolicyDeny { reason_code: String, message: String },
#[error("integrity failure [{reason_code}]: {message}")]
IntegrityFailure { reason_code: String, message: String },
#[error("rate limit exceeded; retry after {retry_after_seconds}s ({message})")]
RateLimitExceeded { retry_after_seconds: u64, message: String },
#[error("risk acceptance required [{reason_code}]: {message}")]
RiskAcceptanceRequired {
reason_code: String,
message: String,
guidance: Option<String>,
docs_url: Option<String>,
},
#[error("authentication failed [{reason_code}]: {message}")]
Authentication { reason_code: String, message: String },
#[error("insufficient data [{reason_code}]: {message}")]
InsufficientData { reason_code: String, message: String },
#[error("package not found: {0}")]
PackageNotFound(String),
#[error("server error {status}: {message}")]
ServerError { status: u16, message: String, retryable: bool },
#[error("transport error: {0}")]
Transport(#[from] TransportError),
#[error("parse error: {0}")]
Parse(String),
}
#[derive(Debug, Error)]
pub enum TransportError {
#[error("network: {0}")]
Network(#[source] reqwest::Error),
#[error("invalid endpoint URL: {0}")]
InvalidUrl(String),
#[error("TLS required; refusing plaintext endpoint {0} (localhost exempt for testing)")]
TlsRequired(String),
#[error("timeout")]
Timeout,
}
impl CleanLibraryError {
pub fn is_retryable(&self) -> bool {
match self {
Self::ServerError { retryable, .. } => *retryable,
Self::Transport(TransportError::Timeout) => true,
Self::Transport(TransportError::Network(_)) => true,
Self::RateLimitExceeded { .. } => true,
_ => false,
}
}
pub fn reason_code(&self) -> Option<&str> {
match self {
Self::PolicyDeny { reason_code, .. }
| Self::IntegrityFailure { reason_code, .. }
| Self::RiskAcceptanceRequired { reason_code, .. }
| Self::Authentication { reason_code, .. }
| Self::InsufficientData { reason_code, .. } => Some(reason_code),
_ => None,
}
}
}
pub fn from_http(
status: u16,
headers: &reqwest::header::HeaderMap,
body: &str,
) -> CleanLibraryError {
let reason_code = headers
.get("X-CleanLibrary-Reason")
.and_then(|v| v.to_str().ok())
.unwrap_or("UNKNOWN")
.to_string();
let message = if body.is_empty() {
format!("HTTP {}", status)
} else {
body.to_string()
};
match (status, reason_code.as_str()) {
(401, _) => CleanLibraryError::Authentication { reason_code, message },
(403, "KEY_INVALID" | "KEY_EXPIRED" | "KEY_SCOPE_INSUFFICIENT") => {
CleanLibraryError::Authentication { reason_code, message }
}
(403, "POLICY_DENY_VERDICT" | "POLICY_DENY_RULE_EXPLICIT") | (451, _) => {
CleanLibraryError::PolicyDeny { reason_code, message }
}
(403, "RISK_ACCEPTANCE_REQUIRED") => CleanLibraryError::RiskAcceptanceRequired {
reason_code,
message,
guidance: None,
docs_url: Some("https://docs.cleanlibrary.io/risk-acceptance".to_string()),
},
(403, "INTEGRITY_FAILURE") => CleanLibraryError::IntegrityFailure { reason_code, message },
(403, "INSUFFICIENT_DATA_FAIL_CLOSED") => {
CleanLibraryError::InsufficientData { reason_code, message }
}
(429, _) => {
let retry_after_seconds = headers
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(60);
CleanLibraryError::RateLimitExceeded { retry_after_seconds, message }
}
(404, _) => CleanLibraryError::PackageNotFound(message),
(500..=599, _) => CleanLibraryError::ServerError {
status,
message,
retryable: matches!(status, 502 | 503 | 504),
},
_ => CleanLibraryError::ServerError {
status,
message,
retryable: false,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::{HeaderMap, HeaderValue};
fn headers_with_reason(code: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("X-CleanLibrary-Reason", HeaderValue::from_str(code).unwrap());
h
}
#[test]
fn maps_401_to_auth() {
let err = from_http(401, &HeaderMap::new(), "bad token");
assert!(matches!(err, CleanLibraryError::Authentication { .. }));
}
#[test]
fn maps_403_policy_deny_verdict() {
let err = from_http(403, &headers_with_reason("POLICY_DENY_VERDICT"), "verdict denies");
assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
assert_eq!(err.reason_code(), Some("POLICY_DENY_VERDICT"));
}
#[test]
fn maps_451_npm_legal_alias_to_policy_deny() {
let err = from_http(451, &HeaderMap::new(), "");
assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
}
#[test]
fn maps_403_risk_acceptance_required() {
let err = from_http(
403,
&headers_with_reason("RISK_ACCEPTANCE_REQUIRED"),
"needs explicit acceptance",
);
match err {
CleanLibraryError::RiskAcceptanceRequired { docs_url, .. } => {
assert!(docs_url.is_some());
}
other => panic!("expected RiskAcceptanceRequired, got {:?}", other),
}
}
#[test]
fn maps_429_with_retry_after() {
let mut h = HeaderMap::new();
h.insert("Retry-After", HeaderValue::from_static("42"));
let err = from_http(429, &h, "throttled");
match err {
CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
assert_eq!(retry_after_seconds, 42);
}
other => panic!("expected RateLimitExceeded, got {:?}", other),
}
assert!(err.is_retryable());
}
#[test]
fn maps_429_default_retry_after() {
let err = from_http(429, &HeaderMap::new(), "");
match err {
CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
assert_eq!(retry_after_seconds, 60);
}
other => panic!("expected RateLimitExceeded, got {:?}", other),
}
}
#[test]
fn maps_404_to_package_not_found() {
let err = from_http(404, &HeaderMap::new(), "not in catalog");
assert!(matches!(err, CleanLibraryError::PackageNotFound(_)));
}
#[test]
fn maps_500s_retryability() {
for status in [500, 501, 505] {
let err = from_http(status, &HeaderMap::new(), "");
match err {
CleanLibraryError::ServerError { retryable, .. } => assert!(!retryable),
_ => panic!("expected ServerError"),
}
}
for status in [502, 503, 504] {
let err = from_http(status, &HeaderMap::new(), "");
match err {
CleanLibraryError::ServerError { retryable, .. } => assert!(retryable),
_ => panic!("expected ServerError"),
}
}
}
#[test]
fn integrity_failure_carries_reason() {
let err = from_http(403, &headers_with_reason("INTEGRITY_FAILURE"), "hash mismatch");
match err {
CleanLibraryError::IntegrityFailure { reason_code, .. } => {
assert_eq!(reason_code, "INTEGRITY_FAILURE");
}
_ => panic!("expected IntegrityFailure"),
}
}
#[test]
fn insufficient_data_carries_reason() {
let err = from_http(
403,
&headers_with_reason("INSUFFICIENT_DATA_FAIL_CLOSED"),
"no verdict",
);
assert!(matches!(err, CleanLibraryError::InsufficientData { .. }));
}
}