Skip to main content

seer_core/
error.rs

1use thiserror::Error;
2
3/// Central error type for all seer-core operations.
4///
5/// Marked `#[non_exhaustive]` so adding a variant is not a semver break for
6/// downstream consumers (matches need a wildcard arm). Third-party error
7/// types (reqwest, hickory) are converted to owned data at the `From`
8/// boundary instead of being embedded, so a major bump of those dependencies
9/// no longer changes this public API.
10#[derive(Error, Debug)]
11#[non_exhaustive]
12pub enum SeerError {
13    #[error("WHOIS lookup failed: {0}")]
14    WhoisError(String),
15
16    #[error("WHOIS server not found for TLD: {0}")]
17    WhoisServerNotFound(String),
18
19    #[error("WHOIS connection failed: {0}")]
20    WhoisConnectionFailed(String),
21
22    #[error("RDAP lookup failed: {0}")]
23    RdapError(String),
24
25    #[error("RDAP bootstrap failed: {0}")]
26    RdapBootstrapError(String),
27
28    #[error("DNS resolution failed: {0}")]
29    DnsError(String),
30
31    /// Low-level resolver transport failure, captured as text at the
32    /// `From<hickory_resolver::net::NetError>` boundary.
33    #[error("DNS resolver error: {0}")]
34    DnsResolverError(String),
35
36    #[error("Invalid domain name: {0}")]
37    InvalidDomain(String),
38
39    #[error("Domain not allowed: TLD '{tld}' is not in the allowlist")]
40    DomainNotAllowed { domain: String, tld: String },
41
42    #[error("Invalid IP address: {0}")]
43    InvalidIpAddress(String),
44
45    #[error("Invalid record type: {0}")]
46    InvalidRecordType(String),
47
48    #[error("HTTP request failed: {0}")]
49    HttpError(String),
50
51    /// Transport-level HTTP failure, captured as owned data at the
52    /// `From<reqwest::Error>` boundary.
53    #[error("Reqwest error: {message}")]
54    ReqwestError {
55        /// Upstream error text (reqwest's `Display`), kept for logging.
56        message: String,
57        /// Whether the failure is transient (connect / timeout / 429 / 5xx),
58        /// classified once while the typed error is still available. Read by
59        /// the retry classifier ([`crate::retry::NetworkRetryClassifier`]).
60        transient: bool,
61    },
62
63    #[error("JSON parsing failed: {0}")]
64    JsonError(#[from] serde_json::Error),
65
66    #[error("Timeout: {0}")]
67    Timeout(String),
68
69    #[error("Rate limited: {0}")]
70    RateLimited(String),
71
72    #[error("Certificate error: {0}")]
73    CertificateError(String),
74
75    #[error("SSL error: {0}")]
76    SslError(String),
77
78    #[error("Bulk operation failed: {context}")]
79    BulkOperationError {
80        context: String,
81        failures: Vec<(String, String)>,
82    },
83
84    #[error("Lookup failed for {domain}: {details}\n\nTip: Try checking the registry directly at: {registry_url}")]
85    LookupFailed {
86        domain: String,
87        details: String,
88        registry_url: String,
89    },
90
91    #[error("Configuration error: {0}")]
92    ConfigError(String),
93
94    #[error("Invalid input: {0}")]
95    InvalidInput(String),
96
97    #[error("{0}")]
98    Other(String),
99
100    #[error("Operation failed after {attempts} attempts: {last_error}")]
101    RetryExhausted {
102        attempts: usize,
103        last_error: Box<SeerError>,
104    },
105}
106
107/// Classifies a reqwest error as transient (worth retrying): connect
108/// failures, timeouts, HTTP 429 and 5xx. Other status codes and request/body
109/// construction errors are permanent; unknown kinds default to transient.
110///
111/// Runs at the `From<reqwest::Error>` boundary — the only place the typed
112/// error is still available — so the enum can store an owned flag instead of
113/// the reqwest type.
114fn is_transient_reqwest_error(error: &reqwest::Error) -> bool {
115    // Connection errors are transient
116    if error.is_connect() {
117        return true;
118    }
119
120    // Timeout errors are transient
121    if error.is_timeout() {
122        return true;
123    }
124
125    // Check HTTP status codes
126    if let Some(status) = error.status() {
127        // 429 Too Many Requests - rate limited, retry with backoff
128        if status.as_u16() == 429 {
129            return true;
130        }
131        // 5xx Server errors are transient
132        if status.is_server_error() {
133            return true;
134        }
135        // 4xx Client errors (except 429) are not retryable
136        return false;
137    }
138
139    // Request/body errors are generally not retryable
140    if error.is_request() || error.is_body() {
141        return false;
142    }
143
144    // Default: assume transient for unknown errors
145    true
146}
147
148impl From<reqwest::Error> for SeerError {
149    fn from(e: reqwest::Error) -> Self {
150        SeerError::ReqwestError {
151            message: e.to_string(),
152            transient: is_transient_reqwest_error(&e),
153        }
154    }
155}
156
157impl From<hickory_resolver::net::NetError> for SeerError {
158    fn from(e: hickory_resolver::net::NetError) -> Self {
159        SeerError::DnsResolverError(e.to_string())
160    }
161}
162
163impl SeerError {
164    /// Returns a sanitized error message safe for external exposure
165    /// (API responses, MCP tool results, Python exceptions).
166    ///
167    /// Transport/network variants collapse to their CATEGORY with no inner
168    /// detail, so an upstream server hostname/URL, a resolver-config fragment,
169    /// a filesystem path, or a raw system error can never reach an external
170    /// consumer. Full detail is still available for internal logging via the
171    /// `Display` impl (`to_string()`) — log that, return this. User-input
172    /// echoes (invalid domain / IP / record type / not-allowed TLD / generic
173    /// input validation) are kept because they are the caller's own input and
174    /// are needed to act on the error.
175    pub fn sanitized_message(&self) -> String {
176        match self {
177            SeerError::WhoisError(_) => "WHOIS lookup failed".to_string(),
178            SeerError::WhoisServerNotFound(_) => "WHOIS server not found for this TLD".to_string(),
179            SeerError::WhoisConnectionFailed(_) => "WHOIS connection failed".to_string(),
180            SeerError::RdapError(_) => "RDAP lookup failed".to_string(),
181            SeerError::RdapBootstrapError(_) => {
182                "RDAP service unavailable for this resource".to_string()
183            }
184            SeerError::DnsError(_) => "DNS resolution failed".to_string(),
185            SeerError::DnsResolverError(_) => "DNS resolution failed".to_string(),
186            SeerError::InvalidDomain(domain) => format!("Invalid domain name: {}", domain),
187            SeerError::DomainNotAllowed { tld, .. } => {
188                format!("Domain not allowed: TLD '{}' is not in the allowlist", tld)
189            }
190            SeerError::InvalidIpAddress(ip) => format!("Invalid IP address: {}", ip),
191            SeerError::InvalidRecordType(rt) => format!("Invalid record type: {}", rt),
192            SeerError::HttpError(_) => "HTTP request failed".to_string(),
193            SeerError::ReqwestError { .. } => "HTTP request failed".to_string(),
194            SeerError::JsonError(_) => "Response parsing failed".to_string(),
195            SeerError::Timeout(_) => "Operation timed out".to_string(),
196            SeerError::RateLimited(_) => "Rate limited - please try again later".to_string(),
197            SeerError::CertificateError(_) => "Certificate validation failed".to_string(),
198            SeerError::SslError(_) => "SSL inspection failed".to_string(),
199            SeerError::BulkOperationError { context, .. } => {
200                format!("Bulk operation partially failed: {}", context)
201            }
202            // Drop `details` (built from upstream RDAP/WHOIS error strings).
203            SeerError::LookupFailed { domain, .. } => format!("Lookup failed for {}", domain),
204            SeerError::ConfigError(_) => "Configuration error".to_string(),
205            // InvalidInput carries user-facing validation messages; the one
206            // sensitive case (an SSRF reserved-IP rejection) is already
207            // IP-redacted by `sanitize_error_for_public` on the lookup path.
208            SeerError::InvalidInput(msg) => format!("Invalid input: {}", msg),
209            SeerError::Other(_) => "Operation failed".to_string(),
210            SeerError::RetryExhausted {
211                attempts,
212                last_error,
213            } => {
214                format!(
215                    "Operation failed after {} attempts: {}",
216                    attempts,
217                    last_error.sanitized_message()
218                )
219            }
220        }
221    }
222}
223
224pub type Result<T> = std::result::Result<T, SeerError>;
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn transport_errors_sanitized_to_category_only() {
232        // Strict policy: the external projection must NOT carry the inner
233        // detail of transport/network errors (upstream server hostnames, URLs,
234        // resolver internals, raw system errors). Full detail remains available
235        // for internal logging via Display (`to_string()`).
236        let cases: &[(SeerError, &str, &str)] = &[
237            (
238                SeerError::HttpError("CT log query failed: 502 Bad Gateway".into()),
239                "HTTP request failed",
240                "502",
241            ),
242            (
243                SeerError::Timeout("connection to whois.example.com timed out".into()),
244                "Operation timed out",
245                "whois.example.com",
246            ),
247            (
248                SeerError::DnsError("invalid nameserver IP: 10.1.2.3".into()),
249                "DNS resolution failed",
250                "10.1.2.3",
251            ),
252            (
253                SeerError::SslError("could not resolve internal.host for SSL: refused".into()),
254                "SSL inspection failed",
255                "internal.host",
256            ),
257            (
258                SeerError::WhoisConnectionFailed("connect to whois.nic.internal:43 refused".into()),
259                "WHOIS connection failed",
260                "internal",
261            ),
262            (
263                SeerError::ConfigError("/home/user/.seer/secret.toml unreadable".into()),
264                "Configuration error",
265                ".seer",
266            ),
267        ];
268        for (err, category, leak) in cases {
269            let msg = err.sanitized_message();
270            assert!(
271                msg.contains(category),
272                "expected category {category}; got: {msg}"
273            );
274            assert!(
275                !msg.contains(leak),
276                "detail '{leak}' must NOT leak into sanitized message; got: {msg}"
277            );
278        }
279    }
280
281    #[test]
282    fn user_input_echoes_are_kept() {
283        // The caller's own input is safe and necessary to act on the error.
284        assert!(SeerError::InvalidDomain("bad_domain".into())
285            .sanitized_message()
286            .contains("bad_domain"));
287        assert!(SeerError::InvalidRecordType("ZZZ".into())
288            .sanitized_message()
289            .contains("ZZZ"));
290    }
291
292    // ---- boundary conversion (owned payloads, classify-at-conversion) ----
293
294    #[tokio::test]
295    async fn reqwest_status_errors_classified_at_conversion() {
296        use wiremock::matchers::{method, path};
297        use wiremock::{Mock, MockServer, ResponseTemplate};
298
299        let server = MockServer::start().await;
300        let cases: &[(u16, bool)] = &[
301            (429, true), // rate limited: retry with backoff
302            (500, true), // 5xx: transient server errors
303            (503, true),
304            (400, false), // other 4xx: permanent
305            (404, false),
306        ];
307        for (status, _) in cases {
308            Mock::given(method("GET"))
309                .and(path(format!("/s{status}")))
310                .respond_with(ResponseTemplate::new(*status))
311                .mount(&server)
312                .await;
313        }
314
315        let client = reqwest::Client::new();
316        for (status, expect_transient) in cases {
317            let reqwest_err = client
318                .get(format!("{}/s{}", server.uri(), status))
319                .send()
320                .await
321                .expect("mock server is reachable")
322                .error_for_status()
323                .expect_err("status is an error");
324            match SeerError::from(reqwest_err) {
325                SeerError::ReqwestError { message, transient } => {
326                    assert_eq!(
327                        transient, *expect_transient,
328                        "status {status} transiency misclassified"
329                    );
330                    // Status-code detail must survive into the owned message.
331                    assert!(
332                        message.contains(&status.to_string()),
333                        "status {status} missing from message: {message}"
334                    );
335                }
336                other => panic!("expected ReqwestError, got {other:?}"),
337            }
338        }
339    }
340
341    #[tokio::test]
342    async fn reqwest_connect_error_classified_transient_at_conversion() {
343        // Bind-and-drop to obtain a loopback port with no listener; the
344        // connect is refused locally, no external network involved.
345        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
346            .await
347            .expect("bind loopback");
348        let port = listener.local_addr().expect("local addr").port();
349        drop(listener);
350
351        let client = reqwest::Client::builder()
352            .timeout(std::time::Duration::from_secs(5))
353            .build()
354            .expect("client builds");
355        let reqwest_err = client
356            .get(format!("http://127.0.0.1:{port}/"))
357            .send()
358            .await
359            .expect_err("connect must fail");
360        match SeerError::from(reqwest_err) {
361            SeerError::ReqwestError { transient, .. } => {
362                assert!(transient, "connect failures must classify as transient");
363            }
364            other => panic!("expected ReqwestError, got {other:?}"),
365        }
366    }
367
368    #[test]
369    fn dns_resolver_error_converts_to_owned_message() {
370        // The hickory type is dropped at the boundary; its detail survives
371        // as text and the Display output is unchanged from the #[from] era.
372        let err: SeerError = hickory_resolver::net::NetError::Timeout.into();
373        match &err {
374            SeerError::DnsResolverError(msg) => {
375                assert!(msg.contains("timed out"), "detail must survive: {msg}");
376            }
377            other => panic!("expected DnsResolverError, got {other:?}"),
378        }
379        assert_eq!(err.to_string(), "DNS resolver error: request timed out");
380    }
381}