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    /// CLEANLIB-536: an RFC 9457 `application/problem+json` structured error.
57    /// Carries the App's problem document — the standard
58    /// `type`/`title`/`status`/`detail`/`instance` members plus the CLEANLIB-536
59    /// extensions `reason_class` (the machine-stable branch key —
60    /// `not_ingested` / `dependency_degraded` / `permanently_blocked` /
61    /// `service_unavailable`), `retryable`, `selfHealing`, and `resolution`.
62    /// Consumers branch on `problem.reason_class` and honor `problem.retryable`
63    /// rather than scraping status codes or the raw body. Emitted when the App
64    /// response is `application/problem+json`; legacy header/status responses
65    /// keep the variants above.
66    #[error("{problem}")]
67    Problem { problem: ProblemDetails },
68
69    /// Network / TLS / timeout / DNS — pre-response transport-layer failure.
70    #[error("transport error: {0}")]
71    Transport(#[from] TransportError),
72
73    /// Response body parse failure (malformed JSON, schema mismatch).
74    #[error("parse error: {0}")]
75    Parse(String),
76}
77
78/// CLEANLIB-536: RFC 9457 `application/problem+json` problem document (reader
79/// side). Mirrors `cleanlib-app::problem::ProblemDetails` as a TOLERANT reader —
80/// every field is defaulted and unknown members are ignored (no
81/// `deny_unknown_fields`), so a new App extension never breaks parsing. Standard
82/// RFC 9457 members (`type`/`title`/`status`/`detail`/`instance`) plus the
83/// CLEANLIB-536 extensions.
84#[derive(Debug, Clone, Default, serde::Deserialize)]
85#[serde(default)]
86pub struct ProblemDetails {
87    /// The `type` URI (`https://cleanlibrary.dev/problems/<slug>`). The RFC 9457
88    /// primary identifier; presence marks a well-formed problem document.
89    #[serde(rename = "type")]
90    pub type_uri: String,
91    /// Short human-readable summary.
92    pub title: String,
93    /// HTTP status echoed in the body.
94    pub status: u16,
95    /// Longer human-readable explanation (optional per RFC 9457).
96    pub detail: Option<String>,
97    /// URI identifying the specific occurrence (optional).
98    pub instance: Option<String>,
99    /// Machine-stable branch key (`not_ingested` / `dependency_degraded` /
100    /// `permanently_blocked` / `service_unavailable`). Prefer this over `status`
101    /// for programmatic branching.
102    pub reason_class: String,
103    /// Whether retrying the SAME request could succeed.
104    pub retryable: bool,
105    /// Present when the App is actively self-healing the degraded dependency.
106    #[serde(rename = "selfHealing")]
107    pub self_healing: Option<bool>,
108    /// Suggested caller action for permanently-blocked / actionable problems.
109    pub resolution: Option<String>,
110}
111
112impl std::fmt::Display for ProblemDetails {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        let class = if self.reason_class.is_empty() {
115            "problem"
116        } else {
117            self.reason_class.as_str()
118        };
119        write!(f, "[{}] {}", class, self.title)?;
120        if let Some(detail) = &self.detail {
121            write!(f, ": {}", detail)?;
122        }
123        Ok(())
124    }
125}
126
127/// Parse an `application/problem+json` body (CLEANLIB-536) into
128/// [`ProblemDetails`], returning `None` when the body is absent or is not a
129/// problem document. A body is treated as a problem document when the response
130/// `Content-Type` is `application/problem+json` OR (defensively, in case a proxy
131/// strips the content-type) the parsed `type` URI is under the canonical
132/// `https://cleanlibrary.dev/problems/` base. A bare JSON error body that merely
133/// happens to carry a `type` field is NOT misclassified.
134pub fn parse_problem_details(
135    headers: &reqwest::header::HeaderMap,
136    body: &str,
137) -> Option<ProblemDetails> {
138    let ct_is_problem = headers
139        .get(reqwest::header::CONTENT_TYPE)
140        .and_then(|v| v.to_str().ok())
141        .map(|ct| ct.starts_with("application/problem+json"))
142        .unwrap_or(false);
143    let problem: ProblemDetails = serde_json::from_str(body).ok()?;
144    if problem.type_uri.is_empty() {
145        return None;
146    }
147    if ct_is_problem || problem.type_uri.starts_with("https://cleanlibrary.dev/problems/") {
148        Some(problem)
149    } else {
150        None
151    }
152}
153
154#[derive(Debug, Error)]
155pub enum TransportError {
156    #[error("network: {0}")]
157    Network(#[source] reqwest::Error),
158
159    #[error("invalid endpoint URL: {0}")]
160    InvalidUrl(String),
161
162    #[error("TLS required; refusing plaintext endpoint {0} (localhost exempt for testing)")]
163    TlsRequired(String),
164
165    #[error("timeout")]
166    Timeout,
167}
168
169impl CleanLibraryError {
170    /// True if a transient retry could plausibly succeed. Callers may use
171    /// this to gate retry/backoff logic. Per Rev 1 §2.3 + App Rev 4 §9.2.
172    pub fn is_retryable(&self) -> bool {
173        match self {
174            Self::ServerError { retryable, .. } => *retryable,
175            Self::Transport(TransportError::Timeout) => true,
176            Self::Transport(TransportError::Network(_)) => true,
177            Self::RateLimitExceeded { .. } => true,
178            _ => false,
179        }
180    }
181
182    /// Reason code from App's `X-CleanLibrary-Reason` header, if any.
183    pub fn reason_code(&self) -> Option<&str> {
184        match self {
185            Self::PolicyDeny { reason_code, .. }
186            | Self::IntegrityFailure { reason_code, .. }
187            | Self::RiskAcceptanceRequired { reason_code, .. }
188            | Self::Authentication { reason_code, .. }
189            | Self::InsufficientData { reason_code, .. } => Some(reason_code),
190            _ => None,
191        }
192    }
193}
194
195/// Map HTTP `status` + headers + body into a `CleanLibraryError` variant.
196/// Reads `X-CleanLibrary-Reason` header per App Rev 4 §9.2 reason codes.
197pub fn from_http(
198    status: u16,
199    headers: &reqwest::header::HeaderMap,
200    body: &str,
201) -> CleanLibraryError {
202    // CLEANLIB-536: an RFC 9457 `application/problem+json` response is surfaced
203    // as the structured `Problem` variant (type/title/detail/reason_class/
204    // retryable/…) so consumers branch on `reason_class` rather than scraping the
205    // status or the raw body. Legacy header/status responses fall through to the
206    // reason-code routing below (back-compat).
207    if let Some(problem) = parse_problem_details(headers, body) {
208        return CleanLibraryError::Problem { problem };
209    }
210
211    let reason_code = headers
212        .get("X-CleanLibrary-Reason")
213        .and_then(|v| v.to_str().ok())
214        .unwrap_or("UNKNOWN")
215        .to_string();
216    let message = if body.is_empty() {
217        format!("HTTP {}", status)
218    } else {
219        body.to_string()
220    };
221
222    match (status, reason_code.as_str()) {
223        (401, _) => CleanLibraryError::Authentication { reason_code, message },
224        (403, "KEY_INVALID" | "KEY_EXPIRED" | "KEY_SCOPE_INSUFFICIENT") => {
225            CleanLibraryError::Authentication { reason_code, message }
226        }
227        (403, "POLICY_DENY_VERDICT" | "POLICY_DENY_RULE_EXPLICIT") | (451, _) => {
228            CleanLibraryError::PolicyDeny { reason_code, message }
229        }
230        (403, "RISK_ACCEPTANCE_REQUIRED") => CleanLibraryError::RiskAcceptanceRequired {
231            reason_code,
232            message,
233            guidance: None,
234            docs_url: Some("https://docs.cleanlibrary.io/risk-acceptance".to_string()),
235        },
236        (403, "INTEGRITY_FAILURE") => CleanLibraryError::IntegrityFailure { reason_code, message },
237        (403, "INSUFFICIENT_DATA_FAIL_CLOSED") => {
238            CleanLibraryError::InsufficientData { reason_code, message }
239        }
240        (429, _) => {
241            let retry_after_seconds = headers
242                .get("Retry-After")
243                .and_then(|v| v.to_str().ok())
244                .and_then(|s| s.parse().ok())
245                .unwrap_or(60);
246            CleanLibraryError::RateLimitExceeded { retry_after_seconds, message }
247        }
248        (404, _) => CleanLibraryError::PackageNotFound(message),
249        (500..=599, _) => CleanLibraryError::ServerError {
250            status,
251            message,
252            retryable: matches!(status, 502 | 503 | 504),
253        },
254        _ => CleanLibraryError::ServerError {
255            status,
256            message,
257            retryable: false,
258        },
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use reqwest::header::{HeaderMap, HeaderValue};
266
267    fn headers_with_reason(code: &str) -> HeaderMap {
268        let mut h = HeaderMap::new();
269        h.insert("X-CleanLibrary-Reason", HeaderValue::from_str(code).unwrap());
270        h
271    }
272
273    fn problem_headers() -> HeaderMap {
274        let mut h = HeaderMap::new();
275        h.insert(
276            reqwest::header::CONTENT_TYPE,
277            HeaderValue::from_static("application/problem+json"),
278        );
279        h
280    }
281
282    // ─── CLEANLIB-536 RFC 9457 problem+json ─────────────────────────────────
283
284    const NOT_INGESTED_BODY: &str = r#"{
285        "type": "https://cleanlibrary.dev/problems/not-ingested",
286        "title": "Package not in CleanLibrary catalog",
287        "status": 404,
288        "detail": "npm/left-pad@99.0.0 has not been ingested",
289        "reason_class": "not_ingested",
290        "retryable": false
291    }"#;
292
293    #[test]
294    fn problem_json_maps_to_structured_problem() {
295        let err = from_http(404, &problem_headers(), NOT_INGESTED_BODY);
296        match err {
297            CleanLibraryError::Problem { problem } => {
298                assert_eq!(problem.type_uri, "https://cleanlibrary.dev/problems/not-ingested");
299                assert_eq!(problem.reason_class, "not_ingested");
300                assert_eq!(problem.status, 404);
301                assert!(!problem.retryable);
302                assert_eq!(problem.detail.as_deref(), Some("npm/left-pad@99.0.0 has not been ingested"));
303            }
304            other => panic!("expected Problem, got {other:?}"),
305        }
306    }
307
308    #[test]
309    fn problem_json_degraded_is_retryable_with_selfhealing() {
310        let body = r#"{
311            "type": "https://cleanlibrary.dev/problems/dependency-degraded-self-healing",
312            "title": "Upstream advisories degraded — self-healing",
313            "status": 503,
314            "reason_class": "dependency_degraded",
315            "retryable": true,
316            "selfHealing": true
317        }"#;
318        match from_http(503, &problem_headers(), body) {
319            CleanLibraryError::Problem { problem } => {
320                assert_eq!(problem.reason_class, "dependency_degraded");
321                assert!(problem.retryable);
322                assert_eq!(problem.self_healing, Some(true));
323            }
324            other => panic!("expected Problem, got {other:?}"),
325        }
326    }
327
328    #[test]
329    fn problem_json_tolerates_unknown_extension_fields() {
330        let body = r#"{
331            "type": "https://cleanlibrary.dev/problems/permanently-blocked",
332            "title": "Verdict requires action to resolve",
333            "status": 422,
334            "reason_class": "permanently_blocked",
335            "retryable": false,
336            "resolution": "upgrade to >=1.2.3",
337            "future_extension_field": {"nested": [1,2,3]}
338        }"#;
339        match from_http(422, &problem_headers(), body) {
340            CleanLibraryError::Problem { problem } => {
341                assert_eq!(problem.reason_class, "permanently_blocked");
342                assert_eq!(problem.resolution.as_deref(), Some("upgrade to >=1.2.3"));
343            }
344            other => panic!("expected Problem, got {other:?}"),
345        }
346    }
347
348    #[test]
349    fn problem_type_uri_sniff_without_content_type() {
350        // Defensive: a proxy strips the content-type but the body is a canonical
351        // problem doc → still surfaced as Problem.
352        let err = from_http(404, &HeaderMap::new(), NOT_INGESTED_BODY);
353        assert!(matches!(err, CleanLibraryError::Problem { .. }));
354    }
355
356    #[test]
357    fn non_problem_body_falls_through_to_legacy_routing() {
358        // A plain JSON error body with a `type` field that is NOT a problems URI
359        // must NOT be misclassified — legacy header/status routing wins.
360        let body = r#"{"type": "some_other_thing", "message": "nope"}"#;
361        let err = from_http(401, &HeaderMap::new(), body);
362        assert!(matches!(err, CleanLibraryError::Authentication { .. }));
363    }
364
365    #[test]
366    fn legacy_reason_header_still_maps_without_problem_json() {
367        // Back-compat: no problem+json → the 429 rate-limit path is unchanged.
368        let err = from_http(429, &headers_with_reason("RATE_LIMIT"), "slow down");
369        assert!(matches!(err, CleanLibraryError::RateLimitExceeded { .. }));
370    }
371
372    #[test]
373    fn maps_401_to_auth() {
374        let err = from_http(401, &HeaderMap::new(), "bad token");
375        assert!(matches!(err, CleanLibraryError::Authentication { .. }));
376    }
377
378    #[test]
379    fn maps_403_policy_deny_verdict() {
380        let err = from_http(403, &headers_with_reason("POLICY_DENY_VERDICT"), "verdict denies");
381        assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
382        assert_eq!(err.reason_code(), Some("POLICY_DENY_VERDICT"));
383    }
384
385    #[test]
386    fn maps_451_npm_legal_alias_to_policy_deny() {
387        let err = from_http(451, &HeaderMap::new(), "");
388        assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
389    }
390
391    #[test]
392    fn maps_403_risk_acceptance_required() {
393        let err = from_http(
394            403,
395            &headers_with_reason("RISK_ACCEPTANCE_REQUIRED"),
396            "needs explicit acceptance",
397        );
398        match err {
399            CleanLibraryError::RiskAcceptanceRequired { docs_url, .. } => {
400                assert!(docs_url.is_some());
401            }
402            other => panic!("expected RiskAcceptanceRequired, got {:?}", other),
403        }
404    }
405
406    #[test]
407    fn maps_429_with_retry_after() {
408        let mut h = HeaderMap::new();
409        h.insert("Retry-After", HeaderValue::from_static("42"));
410        let err = from_http(429, &h, "throttled");
411        match err {
412            CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
413                assert_eq!(retry_after_seconds, 42);
414            }
415            other => panic!("expected RateLimitExceeded, got {:?}", other),
416        }
417        assert!(err.is_retryable());
418    }
419
420    #[test]
421    fn maps_429_default_retry_after() {
422        let err = from_http(429, &HeaderMap::new(), "");
423        match err {
424            CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
425                assert_eq!(retry_after_seconds, 60);
426            }
427            other => panic!("expected RateLimitExceeded, got {:?}", other),
428        }
429    }
430
431    #[test]
432    fn maps_404_to_package_not_found() {
433        let err = from_http(404, &HeaderMap::new(), "not in catalog");
434        assert!(matches!(err, CleanLibraryError::PackageNotFound(_)));
435    }
436
437    #[test]
438    fn maps_500s_retryability() {
439        for status in [500, 501, 505] {
440            let err = from_http(status, &HeaderMap::new(), "");
441            match err {
442                CleanLibraryError::ServerError { retryable, .. } => assert!(!retryable),
443                _ => panic!("expected ServerError"),
444            }
445        }
446        for status in [502, 503, 504] {
447            let err = from_http(status, &HeaderMap::new(), "");
448            match err {
449                CleanLibraryError::ServerError { retryable, .. } => assert!(retryable),
450                _ => panic!("expected ServerError"),
451            }
452        }
453    }
454
455    #[test]
456    fn integrity_failure_carries_reason() {
457        let err = from_http(403, &headers_with_reason("INTEGRITY_FAILURE"), "hash mismatch");
458        match err {
459            CleanLibraryError::IntegrityFailure { reason_code, .. } => {
460                assert_eq!(reason_code, "INTEGRITY_FAILURE");
461            }
462            _ => panic!("expected IntegrityFailure"),
463        }
464    }
465
466    #[test]
467    fn insufficient_data_carries_reason() {
468        let err = from_http(
469            403,
470            &headers_with_reason("INSUFFICIENT_DATA_FAIL_CLOSED"),
471            "no verdict",
472        );
473        assert!(matches!(err, CleanLibraryError::InsufficientData { .. }));
474    }
475}