1use thiserror::Error;
6
7#[derive(Debug, Error)]
10pub enum CleanLibraryError {
11 #[error("config error: {0}")]
12 Config(#[from] crate::config::ConfigError),
13
14 #[error("policy deny [{reason_code}]: {message}")]
17 PolicyDeny { reason_code: String, message: String },
18
19 #[error("integrity failure [{reason_code}]: {message}")]
22 IntegrityFailure { reason_code: String, message: String },
23
24 #[error("rate limit exceeded; retry after {retry_after_seconds}s ({message})")]
26 RateLimitExceeded { retry_after_seconds: u64, message: String },
27
28 #[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 #[error("authentication failed [{reason_code}]: {message}")]
41 Authentication { reason_code: String, message: String },
42
43 #[error("insufficient data [{reason_code}]: {message}")]
46 InsufficientData { reason_code: String, message: String },
47
48 #[error("package not found: {0}")]
50 PackageNotFound(String),
51
52 #[error("server error {status}: {message}")]
54 ServerError { status: u16, message: String, retryable: bool },
55
56 #[error("transport error: {0}")]
58 Transport(#[from] TransportError),
59
60 #[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 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 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
106pub 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}