Skip to main content

cleanlib_client/
errors.rs

1//! `CleanLibraryError` hierarchy per Client spec rev1 §2.3 + Rev 2 amendment §2.
2//! Mirrors App Rev 4 §9.2 reason-code enum surfaced via `X-CleanLibrary-Reason`
3//! response header.
4
5use thiserror::Error;
6
7/// Top-level error type for cleanlib-client operations.
8/// Mirrors Rev 1 §2.3 mapping table.
9#[derive(Debug, Error)]
10pub enum CleanLibraryError {
11    #[error("config error: {0}")]
12    Config(#[from] crate::config::ConfigError),
13
14    /// Policy DENY — `403` + `POLICY_DENY_VERDICT` / `POLICY_DENY_RULE_EXPLICIT`,
15    /// or `451 Unavailable For Legal Reasons` (npm ecosystem alias).
16    #[error("policy deny [{reason_code}]: {message}")]
17    PolicyDeny { reason_code: String, message: String },
18
19    /// `403` + `INTEGRITY_FAILURE` — package hash mismatch on serve path
20    /// (security incident; App refuses serve).
21    #[error("integrity failure [{reason_code}]: {message}")]
22    IntegrityFailure { reason_code: String, message: String },
23
24    /// `429 Too Many Requests` + `Retry-After` header.
25    #[error("rate limit exceeded; retry after {retry_after_seconds}s ({message})")]
26    RateLimitExceeded { retry_after_seconds: u64, message: String },
27
28    /// `403` + `RISK_ACCEPTANCE_REQUIRED` — policy permits ALLOW but customer
29    /// hasn't signed risk-acceptance for this package/version. Customer should
30    /// emit a rule via `cleanlib risk-accept` and upload to CDP admin UI.
31    #[error("risk acceptance required [{reason_code}]: {message}")]
32    RiskAcceptanceRequired {
33        reason_code: String,
34        message: String,
35        guidance: Option<String>,
36        docs_url: Option<String>,
37    },
38
39    /// `401` or `403` + `KEY_INVALID` / `KEY_EXPIRED` / `KEY_SCOPE_INSUFFICIENT`.
40    #[error("authentication failed [{reason_code}]: {message}")]
41    Authentication { reason_code: String, message: String },
42
43    /// `403` + `INSUFFICIENT_DATA_FAIL_CLOSED` — verdict unavailable + customer
44    /// policy fails closed on missing data.
45    #[error("insufficient data [{reason_code}]: {message}")]
46    InsufficientData { reason_code: String, message: String },
47
48    /// `404 Not Found` — package not in catalog + ingest declined or unavailable.
49    #[error("package not found: {0}")]
50    PackageNotFound(String),
51
52    /// `5xx` — server error. `retryable=true` for 502/503/504.
53    #[error("server error {status}: {message}")]
54    ServerError { status: u16, message: String, retryable: bool },
55
56    /// Network / TLS / timeout / DNS — pre-response transport-layer failure.
57    #[error("transport error: {0}")]
58    Transport(#[from] TransportError),
59
60    /// Response body parse failure (malformed JSON, schema mismatch).
61    #[error("parse error: {0}")]
62    Parse(String),
63}
64
65#[derive(Debug, Error)]
66pub enum TransportError {
67    #[error("network: {0}")]
68    Network(#[source] reqwest::Error),
69
70    #[error("invalid endpoint URL: {0}")]
71    InvalidUrl(String),
72
73    #[error("TLS required; refusing plaintext endpoint {0} (localhost exempt for testing)")]
74    TlsRequired(String),
75
76    #[error("timeout")]
77    Timeout,
78}
79
80impl CleanLibraryError {
81    /// True if a transient retry could plausibly succeed. Callers may use
82    /// this to gate retry/backoff logic. Per Rev 1 §2.3 + App Rev 4 §9.2.
83    pub fn is_retryable(&self) -> bool {
84        match self {
85            Self::ServerError { retryable, .. } => *retryable,
86            Self::Transport(TransportError::Timeout) => true,
87            Self::Transport(TransportError::Network(_)) => true,
88            Self::RateLimitExceeded { .. } => true,
89            _ => false,
90        }
91    }
92
93    /// Reason code from App's `X-CleanLibrary-Reason` header, if any.
94    pub fn reason_code(&self) -> Option<&str> {
95        match self {
96            Self::PolicyDeny { reason_code, .. }
97            | Self::IntegrityFailure { reason_code, .. }
98            | Self::RiskAcceptanceRequired { reason_code, .. }
99            | Self::Authentication { reason_code, .. }
100            | Self::InsufficientData { reason_code, .. } => Some(reason_code),
101            _ => None,
102        }
103    }
104}
105
106/// Map HTTP `status` + headers + body into a `CleanLibraryError` variant.
107/// Reads `X-CleanLibrary-Reason` header per App Rev 4 §9.2 reason codes.
108pub fn from_http(
109    status: u16,
110    headers: &reqwest::header::HeaderMap,
111    body: &str,
112) -> CleanLibraryError {
113    let reason_code = headers
114        .get("X-CleanLibrary-Reason")
115        .and_then(|v| v.to_str().ok())
116        .unwrap_or("UNKNOWN")
117        .to_string();
118    let message = if body.is_empty() {
119        format!("HTTP {}", status)
120    } else {
121        body.to_string()
122    };
123
124    match (status, reason_code.as_str()) {
125        (401, _) => CleanLibraryError::Authentication { reason_code, message },
126        (403, "KEY_INVALID" | "KEY_EXPIRED" | "KEY_SCOPE_INSUFFICIENT") => {
127            CleanLibraryError::Authentication { reason_code, message }
128        }
129        (403, "POLICY_DENY_VERDICT" | "POLICY_DENY_RULE_EXPLICIT") | (451, _) => {
130            CleanLibraryError::PolicyDeny { reason_code, message }
131        }
132        (403, "RISK_ACCEPTANCE_REQUIRED") => CleanLibraryError::RiskAcceptanceRequired {
133            reason_code,
134            message,
135            guidance: None,
136            docs_url: Some("https://docs.cleanlibrary.io/risk-acceptance".to_string()),
137        },
138        (403, "INTEGRITY_FAILURE") => CleanLibraryError::IntegrityFailure { reason_code, message },
139        (403, "INSUFFICIENT_DATA_FAIL_CLOSED") => {
140            CleanLibraryError::InsufficientData { reason_code, message }
141        }
142        (429, _) => {
143            let retry_after_seconds = headers
144                .get("Retry-After")
145                .and_then(|v| v.to_str().ok())
146                .and_then(|s| s.parse().ok())
147                .unwrap_or(60);
148            CleanLibraryError::RateLimitExceeded { retry_after_seconds, message }
149        }
150        (404, _) => CleanLibraryError::PackageNotFound(message),
151        (500..=599, _) => CleanLibraryError::ServerError {
152            status,
153            message,
154            retryable: matches!(status, 502 | 503 | 504),
155        },
156        _ => CleanLibraryError::ServerError {
157            status,
158            message,
159            retryable: false,
160        },
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use reqwest::header::{HeaderMap, HeaderValue};
168
169    fn headers_with_reason(code: &str) -> HeaderMap {
170        let mut h = HeaderMap::new();
171        h.insert("X-CleanLibrary-Reason", HeaderValue::from_str(code).unwrap());
172        h
173    }
174
175    #[test]
176    fn maps_401_to_auth() {
177        let err = from_http(401, &HeaderMap::new(), "bad token");
178        assert!(matches!(err, CleanLibraryError::Authentication { .. }));
179    }
180
181    #[test]
182    fn maps_403_policy_deny_verdict() {
183        let err = from_http(403, &headers_with_reason("POLICY_DENY_VERDICT"), "verdict denies");
184        assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
185        assert_eq!(err.reason_code(), Some("POLICY_DENY_VERDICT"));
186    }
187
188    #[test]
189    fn maps_451_npm_legal_alias_to_policy_deny() {
190        let err = from_http(451, &HeaderMap::new(), "");
191        assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
192    }
193
194    #[test]
195    fn maps_403_risk_acceptance_required() {
196        let err = from_http(
197            403,
198            &headers_with_reason("RISK_ACCEPTANCE_REQUIRED"),
199            "needs explicit acceptance",
200        );
201        match err {
202            CleanLibraryError::RiskAcceptanceRequired { docs_url, .. } => {
203                assert!(docs_url.is_some());
204            }
205            other => panic!("expected RiskAcceptanceRequired, got {:?}", other),
206        }
207    }
208
209    #[test]
210    fn maps_429_with_retry_after() {
211        let mut h = HeaderMap::new();
212        h.insert("Retry-After", HeaderValue::from_static("42"));
213        let err = from_http(429, &h, "throttled");
214        match err {
215            CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
216                assert_eq!(retry_after_seconds, 42);
217            }
218            other => panic!("expected RateLimitExceeded, got {:?}", other),
219        }
220        assert!(err.is_retryable());
221    }
222
223    #[test]
224    fn maps_429_default_retry_after() {
225        let err = from_http(429, &HeaderMap::new(), "");
226        match err {
227            CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
228                assert_eq!(retry_after_seconds, 60);
229            }
230            other => panic!("expected RateLimitExceeded, got {:?}", other),
231        }
232    }
233
234    #[test]
235    fn maps_404_to_package_not_found() {
236        let err = from_http(404, &HeaderMap::new(), "not in catalog");
237        assert!(matches!(err, CleanLibraryError::PackageNotFound(_)));
238    }
239
240    #[test]
241    fn maps_500s_retryability() {
242        for status in [500, 501, 505] {
243            let err = from_http(status, &HeaderMap::new(), "");
244            match err {
245                CleanLibraryError::ServerError { retryable, .. } => assert!(!retryable),
246                _ => panic!("expected ServerError"),
247            }
248        }
249        for status in [502, 503, 504] {
250            let err = from_http(status, &HeaderMap::new(), "");
251            match err {
252                CleanLibraryError::ServerError { retryable, .. } => assert!(retryable),
253                _ => panic!("expected ServerError"),
254            }
255        }
256    }
257
258    #[test]
259    fn integrity_failure_carries_reason() {
260        let err = from_http(403, &headers_with_reason("INTEGRITY_FAILURE"), "hash mismatch");
261        match err {
262            CleanLibraryError::IntegrityFailure { reason_code, .. } => {
263                assert_eq!(reason_code, "INTEGRITY_FAILURE");
264            }
265            _ => panic!("expected IntegrityFailure"),
266        }
267    }
268
269    #[test]
270    fn insufficient_data_carries_reason() {
271        let err = from_http(
272            403,
273            &headers_with_reason("INSUFFICIENT_DATA_FAIL_CLOSED"),
274            "no verdict",
275        );
276        assert!(matches!(err, CleanLibraryError::InsufficientData { .. }));
277    }
278}