Skip to main content

minecraft_java_rs_core/net/
client.rs

1//! Centralised reqwest client construction and transport-error description.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::dns::{Addrs, Name, Resolve, Resolving};
8
9/// A DNS resolver that returns only IPv4 addresses.
10///
11/// On networks that advertise IPv6 (AAAA records) but whose IPv6 route is
12/// broken, reqwest/hyper does not fall back to IPv4 the way browsers do (it has
13/// no Happy Eyeballs), so connections hang or fail with the opaque "error
14/// sending request for url". This is a classic "works in the browser and over a
15/// VPN, fails in the app" symptom. Filtering DNS results to IPv4 before reqwest
16/// ever attempts a connection sidesteps the broken IPv6 path entirely.
17#[derive(Debug, Default)]
18struct Ipv4OnlyResolver;
19
20impl Resolve for Ipv4OnlyResolver {
21    fn resolve(&self, name: Name) -> Resolving {
22        let host = name.as_str().to_owned();
23        Box::pin(async move {
24            // Port is irrelevant here — reqwest overwrites it with the real one.
25            let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
26            let v4: Vec<SocketAddr> = addrs.filter(SocketAddr::is_ipv4).collect();
27            Ok(Box::new(v4.into_iter()) as Addrs)
28        })
29    }
30}
31
32/// Build a reqwest client with the shared launcher configuration.
33///
34/// When `force_ipv4` is `true`, DNS resolution is restricted to IPv4 addresses
35/// (see [`Ipv4OnlyResolver`]).
36pub fn build_client(timeout_secs: u64, force_ipv4: bool) -> reqwest::Result<reqwest::Client> {
37    let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(timeout_secs));
38    if force_ipv4 {
39        builder = builder.dns_resolver(Arc::new(Ipv4OnlyResolver));
40    }
41    builder.build()
42}
43
44/// Describe a `reqwest::Error`, surfacing the underlying transport cause when
45/// the request never reached the server (no HTTP status).
46///
47/// reqwest's own `Display` for these cases is the unhelpful "error sending
48/// request for url (…)". This walks the error source chain down to the root
49/// cause (e.g. "Network is unreachable", "Temporary failure in name
50/// resolution", "Connection reset by peer") — the information actually needed
51/// to tell a DNS problem from a broken IPv6 route from an ISP reset.
52pub fn describe_reqwest_error(err: &reqwest::Error) -> String {
53    if let Some(status) = err.status() {
54        let reason = status.canonical_reason().unwrap_or("unknown");
55        return format!("HTTP {status} {reason}");
56    }
57
58    let kind = if err.is_timeout() {
59        "connection timed out"
60    } else if err.is_connect() {
61        "could not establish connection"
62    } else if err.is_request() {
63        "request could not be sent"
64    } else {
65        "network error"
66    };
67
68    // Walk to the deepest cause in the source chain — that is where the real
69    // OS-level reason (DNS / unreachable / reset) lives.
70    let mut root: Option<String> = None;
71    let mut src = std::error::Error::source(err);
72    while let Some(e) = src {
73        root = Some(e.to_string());
74        src = e.source();
75    }
76
77    match root {
78        Some(cause) => format!("{kind} ({cause})"),
79        None => kind.to_string(),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn build_client_succeeds_in_both_modes() {
89        assert!(build_client(10, false).is_ok());
90        assert!(build_client(10, true).is_ok());
91    }
92}