use thiserror::Error;
#[non_exhaustive]
#[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("coverage incomplete [{reason_code}]: {message}")]
CoverageIncomplete { reason_code: String, message: String },
#[error("attestation invalid [{reason_code}]: {message}")]
AttestationInvalid { 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("{problem}")]
Problem { problem: ProblemDetails },
#[error("transport error: {0}")]
Transport(#[from] TransportError),
#[error("parse error: {0}")]
Parse(String),
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(default)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
pub detail: Option<String>,
pub instance: Option<String>,
pub reason_class: String,
pub retryable: bool,
#[serde(rename = "selfHealing")]
pub self_healing: Option<bool>,
pub resolution: Option<String>,
}
impl std::fmt::Display for ProblemDetails {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let class = if self.reason_class.is_empty() {
"problem"
} else {
self.reason_class.as_str()
};
write!(f, "[{}] {}", class, self.title)?;
if let Some(detail) = &self.detail {
write!(f, ": {}", detail)?;
}
Ok(())
}
}
pub fn parse_problem_details(
headers: &reqwest::header::HeaderMap,
body: &str,
) -> Option<ProblemDetails> {
let ct_is_problem = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| ct.starts_with("application/problem+json"))
.unwrap_or(false);
let problem: ProblemDetails = serde_json::from_str(body).ok()?;
if problem.type_uri.is_empty() {
return None;
}
if ct_is_problem || problem.type_uri.starts_with("https://cleanlibrary.dev/problems/") {
Some(problem)
} else {
None
}
}
#[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 {
if let Some(problem) = parse_problem_details(headers, body) {
return CleanLibraryError::Problem { problem };
}
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
}
fn problem_headers() -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_static("application/problem+json"),
);
h
}
const NOT_INGESTED_BODY: &str = r#"{
"type": "https://cleanlibrary.dev/problems/not-ingested",
"title": "Package not in CleanLibrary catalog",
"status": 404,
"detail": "npm/left-pad@99.0.0 has not been ingested",
"reason_class": "not_ingested",
"retryable": false
}"#;
#[test]
fn problem_json_maps_to_structured_problem() {
let err = from_http(404, &problem_headers(), NOT_INGESTED_BODY);
match err {
CleanLibraryError::Problem { problem } => {
assert_eq!(problem.type_uri, "https://cleanlibrary.dev/problems/not-ingested");
assert_eq!(problem.reason_class, "not_ingested");
assert_eq!(problem.status, 404);
assert!(!problem.retryable);
assert_eq!(problem.detail.as_deref(), Some("npm/left-pad@99.0.0 has not been ingested"));
}
other => panic!("expected Problem, got {other:?}"),
}
}
#[test]
fn problem_json_degraded_is_retryable_with_selfhealing() {
let body = r#"{
"type": "https://cleanlibrary.dev/problems/dependency-degraded-self-healing",
"title": "Upstream advisories degraded — self-healing",
"status": 503,
"reason_class": "dependency_degraded",
"retryable": true,
"selfHealing": true
}"#;
match from_http(503, &problem_headers(), body) {
CleanLibraryError::Problem { problem } => {
assert_eq!(problem.reason_class, "dependency_degraded");
assert!(problem.retryable);
assert_eq!(problem.self_healing, Some(true));
}
other => panic!("expected Problem, got {other:?}"),
}
}
#[test]
fn problem_json_tolerates_unknown_extension_fields() {
let body = r#"{
"type": "https://cleanlibrary.dev/problems/permanently-blocked",
"title": "Verdict requires action to resolve",
"status": 422,
"reason_class": "permanently_blocked",
"retryable": false,
"resolution": "upgrade to >=1.2.3",
"future_extension_field": {"nested": [1,2,3]}
}"#;
match from_http(422, &problem_headers(), body) {
CleanLibraryError::Problem { problem } => {
assert_eq!(problem.reason_class, "permanently_blocked");
assert_eq!(problem.resolution.as_deref(), Some("upgrade to >=1.2.3"));
}
other => panic!("expected Problem, got {other:?}"),
}
}
#[test]
fn problem_type_uri_sniff_without_content_type() {
let err = from_http(404, &HeaderMap::new(), NOT_INGESTED_BODY);
assert!(matches!(err, CleanLibraryError::Problem { .. }));
}
#[test]
fn non_problem_body_falls_through_to_legacy_routing() {
let body = r#"{"type": "some_other_thing", "message": "nope"}"#;
let err = from_http(401, &HeaderMap::new(), body);
assert!(matches!(err, CleanLibraryError::Authentication { .. }));
}
#[test]
fn legacy_reason_header_still_maps_without_problem_json() {
let err = from_http(429, &headers_with_reason("RATE_LIMIT"), "slow down");
assert!(matches!(err, CleanLibraryError::RateLimitExceeded { .. }));
}
#[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 { .. }));
}
}