Skip to main content

lychee_lib/utils/
reqwest.rs

1use std::error::Error;
2
3/// A rule for matching error message patterns to human-readable messages
4struct ErrorRule {
5    patterns: &'static [&'static str],
6    message: &'static str,
7}
8
9impl ErrorRule {
10    /// Create a new error rule
11    const fn new(patterns: &'static [&'static str], message: &'static str) -> Self {
12        Self { patterns, message }
13    }
14
15    /// Check if any of the patterns match the given text
16    fn matches(&self, text: &str) -> bool {
17        self.patterns.iter().any(|pattern| text.contains(pattern))
18    }
19
20    /// Get the message for this rule
21    const fn message(&self) -> &'static str {
22        self.message
23    }
24}
25
26/// A builder for creating and matching against multiple error rules
27struct ErrorRules {
28    rules: Vec<ErrorRule>,
29    fallback: Option<String>,
30}
31
32impl ErrorRules {
33    /// Create a new `ErrorRules` builder
34    const fn new() -> Self {
35        Self {
36            rules: Vec::new(),
37            fallback: None,
38        }
39    }
40
41    /// Add a rule to the matcher
42    fn rule(mut self, patterns: &'static [&'static str], message: &'static str) -> Self {
43        self.rules.push(ErrorRule::new(patterns, message));
44        self
45    }
46
47    /// Set a fallback message if no rules match
48    fn fallback(mut self, message: impl Into<String>) -> Self {
49        self.fallback = Some(message.into());
50        self
51    }
52
53    /// Match against the error message and return the appropriate response
54    fn match_error(self, error_msg: &str) -> String {
55        for rule in &self.rules {
56            if rule.matches(error_msg) {
57                return rule.message().to_string();
58            }
59        }
60
61        self.fallback
62            .unwrap_or_else(|| format!("Unhandled error: {error_msg}"))
63    }
64}
65
66/// Analyze the error chain of a reqwest error and return a concise, actionable message.
67///
68/// This traverses the error chain to extract specific failure details and provides
69/// user-friendly explanations with actionable suggestions when possible.
70///
71/// The advantage of this approach is that we can be way more specific about the
72/// errors which can occur, rather than just returning a generic error message.
73/// The downside is that we have to maintain this code as reqwest and hyper
74/// evolve. However, this is a trade-off we are willing to make for better user
75/// experience.
76pub(crate) fn analyze_error_chain(error: &reqwest::Error) -> String {
77    // First check reqwest's built-in categorization
78    if let Some(basic_message) = analyze_basic_reqwest_error(error) {
79        return basic_message;
80    }
81
82    // Traverse error chain for detailed analysis
83    if let Some(chain_message) = analyze_error_source_chain(error) {
84        return chain_message;
85    }
86
87    // Fallback to basic reqwest error categorization
88    fallback_reqwest_analysis(error)
89}
90
91/// Analyze basic reqwest error types first
92fn analyze_basic_reqwest_error(error: &reqwest::Error) -> Option<String> {
93    if error.is_timeout() {
94        return Some(
95            "Request timed out. Try increasing timeout or check server status".to_string(),
96        );
97    }
98
99    if error.is_redirect() {
100        return Some("Too many redirects - check for redirect loops".to_string());
101    }
102
103    if let Some(status) = error.status() {
104        let reason = status.canonical_reason().unwrap_or("Unknown");
105        return Some(format!(
106            "HTTP {status}: {reason} - check URL and server status",
107        ));
108    }
109
110    None
111}
112
113/// Traverse the error chain for detailed analysis
114fn analyze_error_source_chain(error: &reqwest::Error) -> Option<String> {
115    let mut source = error.source();
116    while let Some(err) = source {
117        // Check for I/O errors (most network issues)
118        if let Some(io_error) = err.downcast_ref::<std::io::Error>() {
119            return Some(analyze_io_error(io_error));
120        }
121
122        // Check for hyper-specific errors
123        if let Some(hyper_error) = err.downcast_ref::<hyper::Error>() {
124            return Some(analyze_hyper_error(hyper_error));
125        }
126
127        // Check for URL parsing errors
128        if let Some(url_error) = err.downcast_ref::<url::ParseError>() {
129            return Some(analyze_url_parse_error(*url_error));
130        }
131
132        // Check for generic error types by examining their string representation
133        if let Some(generic_message) = analyze_generic_error_string(&err.to_string()) {
134            return Some(generic_message);
135        }
136
137        source = err.source();
138    }
139
140    None
141}
142
143/// Analyze I/O errors with specific categorization
144fn analyze_io_error(io_error: &std::io::Error) -> String {
145    match io_error.kind() {
146        std::io::ErrorKind::ConnectionRefused => {
147            "Connection refused - server may be down or port blocked".to_string()
148        }
149        std::io::ErrorKind::TimedOut => {
150            "Request timed out. Try increasing timeout or check server status".to_string()
151        }
152        std::io::ErrorKind::NotFound => {
153            "DNS resolution failed - check hostname spelling".to_string()
154        }
155        std::io::ErrorKind::PermissionDenied => {
156            "Permission denied - check firewall or proxy settings".to_string()
157        }
158        std::io::ErrorKind::Other => analyze_io_other_error(io_error),
159        std::io::ErrorKind::NetworkUnreachable => {
160            "Network unreachable. Check internet connection or VPN settings".to_string()
161        }
162        std::io::ErrorKind::AddrNotAvailable => {
163            "Address not available. Check network interface configuration".to_string()
164        }
165        std::io::ErrorKind::AddrInUse => {
166            "Address already in use. Port conflict or service already running".to_string()
167        }
168        std::io::ErrorKind::BrokenPipe => {
169            "Connection broken. Server closed connection unexpectedly".to_string()
170        }
171        std::io::ErrorKind::InvalidData => {
172            "Invalid response data. Server sent malformed response".to_string()
173        }
174        std::io::ErrorKind::UnexpectedEof => {
175            "Connection closed unexpectedly. Server terminated early".to_string()
176        }
177        std::io::ErrorKind::Interrupted => {
178            "Request interrupted. Try again or check for system issues".to_string()
179        }
180        std::io::ErrorKind::Unsupported => {
181            "Operation not supported. Check protocol or server capabilities".to_string()
182        }
183        _ => {
184            // For unknown/uncategorized errors, provide more context
185            let kind_name = format!("{:?}", io_error.kind());
186            match kind_name.as_str() {
187                "Uncategorized" => {
188                    "Connection failed. Check network connectivity and firewall settings"
189                        .to_string()
190                }
191                _ => {
192                    format!("I/O error ({kind_name}). Check network connectivity and server status",)
193                }
194            }
195        }
196    }
197}
198
199/// Analyze I/O errors with kind "Other" using rule-based pattern matching
200fn analyze_io_other_error(io_error: &std::io::Error) -> String {
201    if let Some(inner) = io_error.get_ref() {
202        let inner_msg = inner.to_string();
203
204        // Special case: certificate errors need deeper analysis
205        if inner_msg.contains("certificate") {
206            return analyze_certificate_error(&inner_msg);
207        }
208
209        // Rule-based pattern matching for other inner error types
210        ErrorRules::new()
211            .rule(
212                &["failed to lookup address", "nodename nor servname"],
213                "DNS resolution failed. Check hostname and DNS settings",
214            )
215            .rule(
216                &["Temporary failure in name resolution"],
217                "DNS temporarily unavailable. Try again later",
218            )
219            .rule(
220                &["handshake"],
221                "TLS handshake failed. Check SSL/TLS configuration",
222            )
223            .fallback(format!("Network error: {inner_msg}"))
224            .match_error(&inner_msg)
225    } else {
226        "Connection failed. Check network connectivity and firewall settings".to_string()
227    }
228}
229
230/// Analyze certificate-related errors using pattern matching rules
231fn analyze_certificate_error(error_msg: &str) -> String {
232    ErrorRules::new()
233        .rule(
234            &[
235                "expired",
236                "NotValidAtThisTime",
237                "certificate has expired",
238                "certificate is not valid on",
239            ],
240            "SSL certificate expired. Site needs to renew certificate",
241        )
242        .rule(
243            &["hostname", "NotValidForName"],
244            "SSL certificate hostname mismatch. Check URL spelling",
245        )
246        .rule(
247            &["self signed", "UnknownIssuer", "not trusted"],
248            "SSL certificate not trusted. Use --insecure if site is trusted",
249        )
250        .rule(
251            &["verify failed"],
252            "SSL certificate verification failed. Check certificate validity",
253        )
254        .fallback("SSL certificate error. Check certificate validity")
255        .match_error(error_msg)
256}
257
258/// Analyze hyper-specific errors
259fn analyze_hyper_error(hyper_error: &hyper::Error) -> String {
260    if hyper_error.is_parse() {
261        if hyper_error.is_parse_status() {
262            return "Invalid HTTP status code from server".to_string();
263        }
264        return "Invalid HTTP response format. Server may be misconfigured".to_string();
265    }
266    if hyper_error.is_timeout() {
267        return "Request timed out. Try increasing timeout or check server status".to_string();
268    }
269    if hyper_error.is_user() {
270        if hyper_error.is_body_write_aborted() {
271            return "Request body upload was aborted".to_string();
272        }
273        return "Invalid request format. Check request parameters".to_string();
274    }
275    if hyper_error.is_canceled() {
276        return "Request was canceled".to_string();
277    }
278    if hyper_error.is_closed() {
279        return "Connection was closed unexpectedly".to_string();
280    }
281    if hyper_error.is_incomplete_message() {
282        return "Connection closed before response completed".to_string();
283    }
284
285    let hyper_msg = hyper_error.to_string();
286
287    // Rule-based analysis of hyper error descriptions
288    ErrorRules::new()
289        .rule(
290            &["connection error"],
291            "Connection failed. Check network connectivity and firewall settings",
292        )
293        .rule(
294            &["http2 error"],
295            "HTTP/2 protocol error. Server may not support HTTP/2 properly",
296        )
297        .rule(
298            &["channel closed"],
299            "HTTP connection channel closed unexpectedly",
300        )
301        .rule(
302            &["operation was canceled"],
303            "HTTP operation was canceled before completion",
304        )
305        .rule(
306            &["message head is too large"],
307            "HTTP headers too large. Server response headers exceed limits",
308        )
309        .rule(
310            &["invalid content-length"],
311            "Invalid Content-Length header from server",
312        )
313        .fallback(format!("HTTP protocol error: {hyper_error}"))
314        .match_error(&hyper_msg)
315}
316
317/// Analyze URL parsing errors
318fn analyze_url_parse_error(url_error: url::ParseError) -> String {
319    match url_error {
320        url::ParseError::EmptyHost => "Invalid URL: empty hostname".to_string(),
321        url::ParseError::InvalidDomainCharacter => {
322            "Invalid URL: invalid characters in domain".to_string()
323        }
324        url::ParseError::InvalidPort => "Invalid URL: invalid port number".to_string(),
325        url::ParseError::RelativeUrlWithoutBase => {
326            "Invalid URL: relative URL without base".to_string()
327        }
328        _ => format!("Invalid URL format: {url_error}"),
329    }
330}
331
332/// Analyze generic error strings using a rule-based pattern matching system
333fn analyze_generic_error_string(error_msg: &str) -> Option<String> {
334    // Special case: certificate errors need deeper analysis
335    if error_msg.contains("certificate") {
336        return Some(analyze_certificate_error(error_msg));
337    }
338
339    // Special case: protocol errors need compound condition check
340    if error_msg.contains("protocol") && error_msg.contains("not supported") {
341        return Some("Protocol not supported. Check URL scheme (http/https)".to_string());
342    }
343
344    // Try to match using our rule-based system
345    let result = ErrorRules::new()
346        .rule(
347            &["protocol version"],
348            "TLS protocol version mismatch. The client and server cannot agree on a TLS version. This is often due to outdated system TLS libraries or the server TLS settings.",
349        )
350        .rule(
351            &["handshake", "TLS", "SSL"],
352            "TLS handshake failed. Check SSL/TLS configuration",
353        )
354        .rule(
355            &["name resolution", "hostname"],
356            "DNS resolution failed. Check hostname and DNS settings",
357        )
358        .rule(
359            &["Connection refused", "connection refused"],
360            "Connection refused. Server is not accepting connections (check if service is running)",
361        )
362        .rule(
363            &["Connection reset", "connection reset"],
364            "Connection reset by server. Server forcibly closed connection",
365        )
366        .rule(
367            &["No route to host", "no route"],
368            "No route to host. Check network routing or firewall configuration",
369        )
370        .rule(
371            &["Network is unreachable", "network unreachable"],
372            "Network unreachable. Check internet connection or VPN settings",
373        )
374        .rule(
375            &["timed out", "timeout"],
376            "Request timed out. Try increasing timeout or check server status",
377        )
378        .match_error(error_msg);
379
380    // Only return Some if we actually matched a rule (not the fallback)
381    if result.starts_with("Unhandled error:") {
382        None
383    } else {
384        Some(result)
385    }
386}
387
388/// Fallback analysis using basic reqwest error categorization
389fn fallback_reqwest_analysis(error: &reqwest::Error) -> String {
390    if error.is_connect() {
391        "Connection failed. Check network connectivity and firewall settings".to_string()
392    } else if error.is_request() {
393        "Request failed. Check URL format and parameters".to_string()
394    } else if error.is_decode() {
395        "Response decoding failed. Server returned invalid data".to_string()
396    } else {
397        format!("Request failed: {error}")
398    }
399}