From 3eda42c5e70383c10ce4c29d5692e656348240a8 Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Tue, 19 May 2026 09:54:19 +0300 Subject: [PATCH 1/6] fix(py): send non-object data values as raw body --- crates/primp-python/src/async/client.rs | 13 +++++++++++-- crates/primp-python/src/lib.rs | 26 +++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/primp-python/src/async/client.rs b/crates/primp-python/src/async/client.rs index 49ee6eb..960d0b7 100644 --- a/crates/primp-python/src/async/client.rs +++ b/crates/primp-python/src/async/client.rs @@ -11,6 +11,7 @@ use tokio_util::codec::{BytesCodec, FramedRead}; use crate::client_builder::{configure_client_builder, cookies_to_header_values, IndexMapSSR}; use crate::error::{PrimpErrorEnum, PrimpResult}; use crate::extract_cookies_to_indexmap; +use crate::body_value_to_string; use crate::traits::HeadersTraits; use crate::utils::extract_encoding; @@ -258,9 +259,17 @@ impl AsyncClient { if let Some(content) = content { request_builder = request_builder.body(content); } - // Form data (if provided) + // Form data (if provided) — only form-encode objects; send scalars as raw body if let Some(form_data) = data_value { - request_builder = request_builder.form(&form_data); + match form_data { + Value::Object(_) => { + request_builder = request_builder.form(&form_data); + } + other => { + let body = body_value_to_string(&other); + request_builder = request_builder.body(body); + } + } } // JSON (if provided) if let Some(json_data) = json_value { diff --git a/crates/primp-python/src/lib.rs b/crates/primp-python/src/lib.rs index 8485e58..4f23b21 100644 --- a/crates/primp-python/src/lib.rs +++ b/crates/primp-python/src/lib.rs @@ -88,6 +88,20 @@ pub fn extract_cookies_to_indexmap(headers: &http::HeaderMap) -> IndexMapSSR { cookie_map } +/// Convert a non-Object `serde_json::Value` to a raw string body. +pub(crate) fn body_value_to_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + Value::Array(arr) => { + serde_json::to_string(arr).unwrap_or_default() + } + Value::Object(_) => unreachable!("body_value_to_string should not be called with Object"), + } +} + #[pymethods] impl Client { /// Initializes an HTTP client that can impersonate web browsers. @@ -370,9 +384,17 @@ impl Client { if let Some(content) = content { request_builder = request_builder.body(content); } - // Form data (if provided) + // Form data (if provided) — only form-encode objects; send scalars as raw body if let Some(form_data) = data_value { - request_builder = request_builder.form(&form_data); + match form_data { + Value::Object(_) => { + request_builder = request_builder.form(&form_data); + } + other => { + let body = body_value_to_string(&other); + request_builder = request_builder.body(body); + } + } } // JSON (if provided) if let Some(json_data) = json_value { From 321359cb425ef6b9aff776a5392518dfb82a812f Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Tue, 19 May 2026 09:54:19 +0300 Subject: [PATCH 2/6] feat(reqwest): add DNS resolver customization (DoH, DoT, plain) with Python bindings --- Cargo.lock | 56 +++++- crates/primp-python/Cargo.toml | 1 + crates/primp-python/docs/async_client.md | 1 + crates/primp-python/docs/client.md | 1 + crates/primp-python/docs/dns.md | 73 +++++++ crates/primp-python/examples/dns_resolver.py | 65 ++++++ crates/primp-python/primp.pyi | 2 + crates/primp-python/src/async/client.rs | 11 +- crates/primp-python/src/client_builder.rs | 74 +++++++ crates/primp-python/src/lib.rs | 21 +- crates/primp-reqwest/Cargo.toml | 8 +- crates/primp-reqwest/src/blocking/client.rs | 9 +- crates/primp-reqwest/src/dns/doh.rs | 198 +++++++++++++++++++ crates/primp-reqwest/src/dns/dot.rs | 145 ++++++++++++++ crates/primp-reqwest/src/dns/gai.rs | 2 + crates/primp-reqwest/src/dns/hickory.rs | 4 +- crates/primp-reqwest/src/dns/mod.rs | 21 +- crates/primp-reqwest/src/dns/plain.rs | 155 +++++++++++++++ crates/primp-reqwest/src/dns/resolve.rs | 43 +++- crates/primp/src/lib.rs | 14 ++ 20 files changed, 880 insertions(+), 24 deletions(-) create mode 100644 crates/primp-python/docs/dns.md create mode 100644 crates/primp-python/examples/dns_resolver.py create mode 100644 crates/primp-reqwest/src/dns/doh.rs create mode 100644 crates/primp-reqwest/src/dns/dot.rs create mode 100644 crates/primp-reqwest/src/dns/plain.rs diff --git a/Cargo.lock b/Cargo.lock index 76e0be4..5b1e446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,6 +895,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h3" version = "0.0.8" @@ -985,22 +1004,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", + "bytes", "cfg-if", "data-encoding", "enum-as-inner", "futures-channel", "futures-io", "futures-util", + "h2", + "http", "idna", "ipnet", "once_cell", "rand 0.9.4", "ring", + "rustls", "thiserror 2.0.18", "tinyvec", "tokio", + "tokio-rustls", "tracing", "url", + "webpki-roots 0.26.11", ] [[package]] @@ -1018,10 +1043,13 @@ dependencies = [ "parking_lot", "rand 0.9.4", "resolv-conf", + "rustls", "smallvec", "thiserror 2.0.18", "tokio", + "tokio-rustls", "tracing", + "webpki-roots 0.26.11", ] [[package]] @@ -2068,7 +2096,7 @@ dependencies = [ "rustls-platform-verifier", "tokio", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2184,7 +2212,7 @@ dependencies = [ "wasm-bindgen-test", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", "zstd", ] @@ -2214,7 +2242,7 @@ dependencies = [ "serde_json", "subtle", "time", - "webpki-roots", + "webpki-roots 1.0.7", "x509-parser 0.16.0", "zeroize", "zlib-rs", @@ -2231,7 +2259,7 @@ dependencies = [ "primp-rustls", "rcgen 0.14.8", "tokio", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2667,6 +2695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -3210,6 +3239,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -3659,6 +3698,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/crates/primp-python/Cargo.toml b/crates/primp-python/Cargo.toml index 04e828b..4e3c3a8 100644 --- a/crates/primp-python/Cargo.toml +++ b/crates/primp-python/Cargo.toml @@ -20,6 +20,7 @@ pyo3-log = "0.13.2" primp = { version = "1.2", path = "../primp", features = [ "impersonate", "socks", + "hickory-dns", ] } bytes = "1" encoding_rs = { version = "0.8.35" } diff --git a/crates/primp-python/docs/async_client.md b/crates/primp-python/docs/async_client.md index e9f253d..a36cc77 100644 --- a/crates/primp-python/docs/async_client.md +++ b/crates/primp-python/docs/async_client.md @@ -26,6 +26,7 @@ primp.AsyncClient( ca_cert_file=None, https_only=False, http2_only=False, + dns_resolver=None, base_url=None, cookies=None, ) diff --git a/crates/primp-python/docs/client.md b/crates/primp-python/docs/client.md index a43c59b..44a66d4 100644 --- a/crates/primp-python/docs/client.md +++ b/crates/primp-python/docs/client.md @@ -24,6 +24,7 @@ primp.Client( ca_cert_file=None, # Path to CA certificate https_only=False, # HTTPS only mode http2_only=False, # HTTP/2 only mode + dns_resolver=None, # DNS resolver: str, list[str], or None (see docs/dns.md) base_url=None, # Base URL for relative paths cookies=None, # Initial cookies to send with all requests ) diff --git a/crates/primp-python/docs/dns.md b/crates/primp-python/docs/dns.md new file mode 100644 index 0000000..7df1d40 --- /dev/null +++ b/crates/primp-python/docs/dns.md @@ -0,0 +1,73 @@ +# DNS Resolution + +Control how DNS lookups are performed by passing `dns_resolver` to `Client()` or `AsyncClient()`. + +## Quick Reference + +| Value | Behavior | +|-------|----------| +| `None` (default) | System resolver | +| `"system"` | System resolver | +| `"doh://.../dns-query"` | DNS-over-HTTPS | +| `"dot://1.1.1.1"` | DNS-over-TLS | +| `"dns://1.1.1.1"` | Plain DNS on port 53 | +| `"1.1.1.1"` | Plain DNS on port 53 (shorthand) | +| `["doh://...", "dot://..."]` | Fallback chain (first success wins) | + +## Examples + +**System resolver (default):** +```python +client = primp.Client() +``` + +**DNS-over-HTTPS via Cloudflare:** +```python +client = primp.Client(dns_resolver="doh://cloudflare-dns.com/dns-query") +``` + +**DNS-over-TLS:** +```python +client = primp.Client(dns_resolver="dot://1.1.1.1") +``` + +**Plain DNS:** +```python +client = primp.Client(dns_resolver="1.1.1.1") +# or explicitly: +client = primp.Client(dns_resolver="dns://1.1.1.1") +``` + +**Fallback chain: try DoH first, fall back to plain DNS:** +```python +client = primp.Client(dns_resolver=["doh://cloudflare-dns.com/dns-query", "1.1.1.1"]) +``` + +**Fallback chain including system resolver:** +```python +client = primp.Client(dns_resolver=["doh://cloudflare-dns.com/dns-query", "system", "1.1.1.1"]) +``` + +**Async:** +```python +client = primp.AsyncClient(dns_resolver="dot://dns.google") +``` + +## Order Matters + +Resolvers in a list are tried in order. The first one that succeeds wins. + +```python +# Try DoH first, fall back to system +client = primp.Client(dns_resolver=["doh://cloudflare-dns.com/dns-query", "system"]) +``` + +## Scheme Reference + +| Scheme | Protocol | Default Port | Example | +|--------|----------|-------------|---------| +| `doh://` | HTTPS (DoH) | 443 | `doh://cloudflare-dns.com/dns-query` | +| `dot://` | TLS (DoT) | 853 | `dot://1.1.1.1` | +| `dns://` | UDP/TCP | 53 | `dns://8.8.8.8` | +| *(none)* | UDP/TCP (plain) | 53 | `1.1.1.1` | +| `system` | OS `getaddrinfo` | — | `system` | diff --git a/crates/primp-python/examples/dns_resolver.py b/crates/primp-python/examples/dns_resolver.py new file mode 100644 index 0000000..c907c15 --- /dev/null +++ b/crates/primp-python/examples/dns_resolver.py @@ -0,0 +1,65 @@ +"""DNS resolver examples.""" + +import primp + + +def system_resolver(): + """Default: system resolver.""" + client = primp.Client() + resp = client.get("https://httpbin.org/get") + print(resp.status_code) + + +def doh_resolver(): + """DNS-over-HTTPS via Cloudflare.""" + client = primp.Client(dns_resolver="doh://cloudflare-dns.com/dns-query") + resp = client.get("https://httpbin.org/get") + print(resp.status_code) + + +def dot_resolver(): + """DNS-over-TLS.""" + client = primp.Client(dns_resolver="dot://1.1.1.1") + resp = client.get("https://httpbin.org/get") + print(resp.status_code) + + +def plain_dns(): + """Plain DNS on port 53 (shorthand and explicit).""" + client = primp.Client(dns_resolver="1.1.1.1") + resp = client.get("https://httpbin.org/get") + print(resp.status_code) + + +def fallback_chain(): + """Try DoH first, fall back through system to plain DNS.""" + client = primp.Client( + dns_resolver=[ + "doh://cloudflare-dns.com/dns-query", + "system", + "1.1.1.1", + ] + ) + resp = client.get("https://httpbin.org/get") + print(resp.status_code) + + +def async_example(): + """Async with DNS resolver.""" + import asyncio + + async def main(): + client = primp.AsyncClient(dns_resolver="dot://dns.google") + resp = await client.get("https://httpbin.org/get") + print(resp.status_code) + + asyncio.run(main()) + + +if __name__ == "__main__": + system_resolver() + doh_resolver() + dot_resolver() + plain_dns() + fallback_chain() + async_example() diff --git a/crates/primp-python/primp.pyi b/crates/primp-python/primp.pyi index 314ac3c..9fa5212 100644 --- a/crates/primp-python/primp.pyi +++ b/crates/primp-python/primp.pyi @@ -276,6 +276,7 @@ class Client: ca_cert_file: str | None = None, https_only: bool = False, http2_only: bool = False, + dns_resolver: str | list[str] | None = None, base_url: str | None = None, cookies: Mapping[str, str] | None = None, ) -> None: ... @@ -480,6 +481,7 @@ class AsyncClient: ca_cert_file: str | None = None, https_only: bool = False, http2_only: bool = False, + dns_resolver: str | list[str] | None = None, base_url: str | None = None, cookies: Mapping[str, str] | None = None, ) -> None: ... diff --git a/crates/primp-python/src/async/client.rs b/crates/primp-python/src/async/client.rs index 960d0b7..998c965 100644 --- a/crates/primp-python/src/async/client.rs +++ b/crates/primp-python/src/async/client.rs @@ -8,10 +8,12 @@ use serde_json::Value; use tokio::fs::File; use tokio_util::codec::{BytesCodec, FramedRead}; -use crate::client_builder::{configure_client_builder, cookies_to_header_values, IndexMapSSR}; +use crate::body_value_to_string; +use crate::client_builder::{ + configure_client_builder, cookies_to_header_values, parse_dns_resolver, IndexMapSSR, +}; use crate::error::{PrimpErrorEnum, PrimpResult}; use crate::extract_cookies_to_indexmap; -use crate::body_value_to_string; use crate::traits::HeadersTraits; use crate::utils::extract_encoding; @@ -50,7 +52,7 @@ impl AsyncClient { referer=true, proxy=None, timeout=None, connect_timeout=None, read_timeout=None, impersonate=None, impersonate_os=None, follow_redirects=true, max_redirects=20, verify=true, ca_cert_file=None, https_only=false, http2_only=false, - base_url=None, cookies=None))] + dns_resolver=None, base_url=None, cookies=None))] fn new( py: Python<'_>, auth: Option<(String, Option)>, @@ -71,9 +73,11 @@ impl AsyncClient { ca_cert_file: Option, https_only: Option, http2_only: Option, + dns_resolver: Option>, base_url: Option, cookies: Option, ) -> PrimpResult { + let dns_resolvers = parse_dns_resolver(dns_resolver)?; let (resolved_proxy, client) = py.detach(|| -> PrimpResult<_> { let (client_builder, resolved_proxy) = configure_client_builder( PrimpClient::builder(), @@ -92,6 +96,7 @@ impl AsyncClient { ca_cert_file, https_only, http2_only, + dns_resolvers, )?; let client = Arc::new(RwLock::new(client_builder.build()?)); diff --git a/crates/primp-python/src/client_builder.rs b/crates/primp-python/src/client_builder.rs index 3cd7cbd..af8cf6f 100644 --- a/crates/primp-python/src/client_builder.rs +++ b/crates/primp-python/src/client_builder.rs @@ -9,10 +9,13 @@ use std::time::Duration; use foldhash::fast::RandomState; use indexmap::IndexMap; use primp::{ + dns::Resolve, header::{HeaderMap, HeaderValue}, redirect::Policy, Client as PrimpClient, ClientBuilder, Proxy, Url, }; +use pyo3::prelude::*; +use pyo3::types::PyList; use crate::error::{PrimpErrorEnum, PrimpResult}; use crate::impersonate::{ @@ -25,6 +28,70 @@ use crate::utils::load_ca_certs; /// Type alias for IndexMap with String keys and values. pub type IndexMapSSR = IndexMap; +/// Parse a resolver string into an `Arc`. +/// +/// Supported formats: +/// - `doh:///path` → DoH resolver (e.g. `doh://cloudflare-dns.com/dns-query`) +/// - `dot://` → DoT resolver (e.g. `dot://1.1.1.1`) +/// - `dns://` or bare `` → plain DNS resolver on port 53 +/// - `system` → system resolver +fn parse_single_resolver(s: &str) -> PrimpResult> { + if let Some(url) = s.strip_prefix("doh://") { + let doh_url = format!("https://{url}"); + let resolver = primp::dns::doh::DohResolver::new(&doh_url) + .map_err(|e| PrimpErrorEnum::Custom(format!("invalid DoH URL: {e}")))?; + Ok(Arc::new(resolver)) + } else if let Some(host) = s.strip_prefix("dot://") { + Ok(Arc::new(primp::dns::dot::DotResolver::new(host))) + } else { + let host = s.strip_prefix("dns://").unwrap_or(s); + if host.is_empty() { + return Err(PrimpErrorEnum::Custom( + "dns:// URL must have a host".into(), + )); + } + if host == "system" { + return Ok(Arc::new(primp::dns::gai::GaiResolver::new())); + } + Ok(Arc::new(primp::dns::plain::PlainDnsResolver::new(host))) + } +} + +/// Parse `dns_resolver` Python argument into a `Vec>`. +/// +/// - `None` → system default +/// - `str` → single resolver +/// - `list[str]` → fallback chain (order matters: first success wins) +pub fn parse_dns_resolver( + obj: Option>, +) -> PrimpResult>> { + let Some(obj) = obj else { + return Ok(Vec::new()); + }; + if let Ok(s) = obj.cast::() { + return Ok(vec![parse_single_resolver( + &s.to_cow() + .map_err(|e| PrimpErrorEnum::Custom(e.to_string()))?, + )?]); + } + if let Ok(list) = obj.cast::() { + let mut resolvers = Vec::with_capacity(list.len()); + for item in list.iter() { + let s = item.cast::().map_err(|_| { + PrimpErrorEnum::Custom("each item in dns_resolver list must be a string".into()) + })?; + resolvers.push(parse_single_resolver( + &s.to_cow() + .map_err(|e| PrimpErrorEnum::Custom(e.to_string()))?, + )?); + } + return Ok(resolvers); + } + Err(PrimpErrorEnum::Custom( + "dns_resolver must be a string, list of strings, or None".into(), + )) +} + /// Applies common configuration to a client builder. /// /// This function handles all configuration that is shared between @@ -56,6 +123,7 @@ pub type IndexMapSSR = IndexMap; /// * `ca_cert_file` - Optional path to CA certificate file /// * `https_only` - Whether to restrict to HTTPS only /// * `http2_only` - Whether to use HTTP/2 only +/// * `dns_resolvers` - Parsed DNS resolvers (empty = system default) /// /// # Returns /// @@ -77,6 +145,7 @@ pub fn configure_client_builder( ca_cert_file: Option, https_only: Option, http2_only: Option, + dns_resolvers: Vec>, ) -> PrimpResult<(ClientBuilder, Option)> { // Impersonate if let Some(imp) = impersonate { @@ -158,6 +227,11 @@ pub fn configure_client_builder( builder = builder.http2_prior_knowledge(); } + // DNS resolver (fallback chain) + if !dns_resolvers.is_empty() { + builder = builder.dns_resolver(dns_resolvers); + } + Ok((builder, proxy)) } diff --git a/crates/primp-python/src/lib.rs b/crates/primp-python/src/lib.rs index 4f23b21..c5a10fa 100644 --- a/crates/primp-python/src/lib.rs +++ b/crates/primp-python/src/lib.rs @@ -14,7 +14,9 @@ use tokio::{ use tokio_util::codec::{BytesCodec, FramedRead}; mod client_builder; -use client_builder::{configure_client_builder, cookies_to_header_values, IndexMapSSR}; +use client_builder::{ + configure_client_builder, cookies_to_header_values, parse_dns_resolver, IndexMapSSR, +}; mod error; use error::{PrimpErrorEnum, PrimpResult}; @@ -95,9 +97,7 @@ pub(crate) fn body_value_to_string(v: &Value) -> String { Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), Value::Null => String::new(), - Value::Array(arr) => { - serde_json::to_string(arr).unwrap_or_default() - } + Value::Array(arr) => serde_json::to_string(arr).unwrap_or_default(), Value::Object(_) => unreachable!("body_value_to_string should not be called with Object"), } } @@ -160,7 +160,7 @@ impl Client { referer=true, proxy=None, timeout=None, connect_timeout=None, read_timeout=None, impersonate=None, impersonate_os=None, follow_redirects=true, max_redirects=20, verify=true, ca_cert_file=None, https_only=false, http2_only=false, - base_url=None, cookies=None))] + dns_resolver=None, base_url=None, cookies=None))] fn new( py: Python<'_>, auth: Option<(String, Option)>, @@ -181,9 +181,11 @@ impl Client { ca_cert_file: Option, https_only: Option, http2_only: Option, + dns_resolver: Option>, base_url: Option, cookies: Option, ) -> PrimpResult { + let dns_resolvers = parse_dns_resolver(dns_resolver)?; let (resolved_proxy, client) = py.detach(|| -> PrimpResult<_> { let (client_builder, resolved_proxy) = configure_client_builder( PrimpClient::builder(), @@ -202,6 +204,7 @@ impl Client { ca_cert_file, https_only, http2_only, + dns_resolvers, )?; let client = Arc::new(RwLock::new(client_builder.build()?)); @@ -843,6 +846,7 @@ fn get( None, None, None, + None, )?; client.get( py, @@ -931,6 +935,7 @@ fn head( None, None, None, + None, )?; client.head( py, @@ -1019,6 +1024,7 @@ fn options( None, None, None, + None, )?; client.options( py, @@ -1107,6 +1113,7 @@ fn delete( None, None, None, + None, )?; client.delete( py, @@ -1195,6 +1202,7 @@ fn post( None, None, None, + None, )?; client.post( py, @@ -1283,6 +1291,7 @@ fn put( None, None, None, + None, )?; client.put( py, @@ -1371,6 +1380,7 @@ fn patch( None, None, None, + None, )?; client.patch( py, @@ -1461,6 +1471,7 @@ fn request( None, None, None, + None, )?; client.request( py, diff --git a/crates/primp-reqwest/Cargo.toml b/crates/primp-reqwest/Cargo.toml index 0fc06e4..b744b0e 100644 --- a/crates/primp-reqwest/Cargo.toml +++ b/crates/primp-reqwest/Cargo.toml @@ -70,7 +70,13 @@ json = ["dep:serde", "dep:serde_json"] multipart = ["dep:mime_guess", "dep:futures-util"] -hickory-dns = ["dep:hickory-resolver", "dep:once_cell"] +hickory-dns = [ + "dep:hickory-resolver", + "dep:once_cell", + "hickory-resolver/https-aws-lc-rs", + "hickory-resolver/tls-aws-lc-rs", + "hickory-resolver/webpki-roots", +] stream = ["tokio/fs", "dep:futures-util", "dep:tokio-util", "dep:wasm-streams"] diff --git a/crates/primp-reqwest/src/blocking/client.rs b/crates/primp-reqwest/src/blocking/client.rs index fbd2d71..4cba133 100644 --- a/crates/primp-reqwest/src/blocking/client.rs +++ b/crates/primp-reqwest/src/blocking/client.rs @@ -1139,10 +1139,15 @@ impl ClientBuilder { /// Override the DNS resolver implementation. /// - /// Pass an `Arc` wrapping a trait object implementing `Resolve`. + /// Accepts any type implementing `IntoResolve`, such as `DohResolver`, `DotResolver`, + /// `GaiResolver`, or a `Vec` of resolvers for fallback. + /// /// Overrides for specific names passed to `resolve` and `resolve_to_addrs` will /// still be applied on top of this resolver. - pub fn dns_resolver(self, resolver: Arc) -> ClientBuilder { + pub fn dns_resolver(self, resolver: R) -> ClientBuilder + where + R: crate::dns::IntoResolve, + { self.with_inner(|inner| inner.dns_resolver(resolver)) } diff --git a/crates/primp-reqwest/src/dns/doh.rs b/crates/primp-reqwest/src/dns/doh.rs new file mode 100644 index 0000000..da312e8 --- /dev/null +++ b/crates/primp-reqwest/src/dns/doh.rs @@ -0,0 +1,198 @@ +//! DNS-over-HTTPS (DoH) resolution via hickory-resolver + +use hickory_resolver::{ + config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, + name_server::TokioConnectionProvider, + proto::xfer::Protocol, + TokioResolver, +}; + +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use super::{Addrs, Name, Resolve, Resolving, SocketAddrs}; +use super::gai::GaiResolver; +use crate::error::BoxError; + +/// A DNS-over-HTTPS resolver backed by hickory-resolver. +pub struct DohResolver { + state: Arc>>>, + bootstrap: Arc, + doh_host: String, + doh_path: String, + doh_port: u16, +} + +impl std::fmt::Debug for DohResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DohResolver") + .field("doh_host", &self.doh_host) + .field("doh_path", &self.doh_path) + .field("doh_port", &self.doh_port) + .finish() + } +} + +impl Clone for DohResolver { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + bootstrap: self.bootstrap.clone(), + doh_host: self.doh_host.clone(), + doh_path: self.doh_path.clone(), + doh_port: self.doh_port, + } + } +} + +impl DohResolver { + /// Create a new DoH resolver from a URL like `https://cloudflare-dns.com/dns-query`. + /// + /// The host is resolved via the system resolver (GaiResolver) on first lookup. + pub fn new(url: &str) -> Result { + let parsed = url::Url::parse(url)?; + let host = parsed.host_str().ok_or("DoH URL must have a host")?.to_string(); + // Strip IPv6 brackets; url::host_str() includes them + let host = host.trim_start_matches('[').trim_end_matches(']').to_string(); + let port = parsed.port().unwrap_or(443); + let path = parsed.path().to_string(); + let bootstrap: Arc = Arc::new(GaiResolver::new()); + Ok(Self { + state: Arc::new(Mutex::new(None)), + bootstrap, + doh_host: host, + doh_path: path, + doh_port: port, + }) + } + + async fn get_resolver(&self) -> Result, BoxError> { + if let Some(ref resolver) = *self.state.lock().unwrap() { + return Ok(resolver.clone()); + } + + let addrs = self + .bootstrap + .resolve(Name::from_str(&self.doh_host)?) + .await?; + let ips: Vec = addrs.map(|a| a.ip()).collect(); + + let mut group = NameServerConfigGroup::with_capacity(ips.len()); + for &ip in &ips { + group.push(NameServerConfig { + socket_addr: SocketAddr::new(ip, self.doh_port), + protocol: Protocol::Https, + tls_dns_name: Some(self.doh_host.clone()), + http_endpoint: Some(self.doh_path.clone()), + trust_negative_responses: true, + bind_addr: None, + }); + } + let config = ResolverConfig::from_parts(None, vec![], group); + + let mut builder = + TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); + let opts = builder.options_mut(); + opts.timeout = Duration::from_secs(5); + opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; + let resolver = Arc::new(builder.build()); + + let mut guard = self.state.lock().unwrap(); + if guard.is_none() { + *guard = Some(resolver.clone()); + } + Ok(guard.as_ref().unwrap().clone()) + } +} + +impl Resolve for DohResolver { + fn resolve(&self, name: Name) -> Resolving { + let this = self.clone(); + Box::pin(async move { + let resolver = this.get_resolver().await?; + let lookup = resolver.lookup_ip(name.as_str()).await?; + let addrs: Addrs = Box::new(SocketAddrs { + iter: lookup.into_iter(), + }); + Ok(addrs) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Client; + + #[test] + fn new_cloudflare() { + let resolver = DohResolver::new("https://cloudflare-dns.com/dns-query").unwrap(); + assert_eq!(resolver.doh_host, "cloudflare-dns.com"); + assert_eq!(resolver.doh_port, 443); + assert_eq!(resolver.doh_path, "/dns-query"); + } + + #[test] + fn new_custom_port() { + let resolver = DohResolver::new("https://dns.google:8443/dns-query").unwrap(); + assert_eq!(resolver.doh_host, "dns.google"); + assert_eq!(resolver.doh_port, 8443); + assert_eq!(resolver.doh_path, "/dns-query"); + } + + #[test] + fn new_ipv6_literal() { + let resolver = DohResolver::new("https://[2606:4700:4700::1111]/dns-query").unwrap(); + assert_eq!(resolver.doh_host, "2606:4700:4700::1111"); + assert_eq!(resolver.doh_port, 443); + assert_eq!(resolver.doh_path, "/dns-query"); + } + + #[test] + fn new_rejects_invalid_url() { + let err = DohResolver::new("not a url").unwrap_err(); + assert!(err.to_string().contains("relative URL"), "{err}"); + } + + #[test] + fn builder_creates_with_doh_resolver() { + let resolver = DohResolver::new("https://cloudflare-dns.com/dns-query").unwrap(); + let client = Client::builder() + .dns_resolver(resolver) + .build(); + assert!(client.is_ok()); + } + + #[test] + fn builder_creates_with_dot_resolver() { + use crate::dns::dot::DotResolver; + let resolver = DotResolver::new("1.1.1.1"); + let client = Client::builder() + .dns_resolver(resolver) + .build(); + assert!(client.is_ok()); + } + + #[test] + fn builder_creates_with_multi_resolver() { + let r1: Arc = Arc::new( + DohResolver::new("https://cloudflare-dns.com/dns-query").unwrap(), + ); + let r2: Arc = Arc::new(crate::dns::gai::GaiResolver::new()); + let client = Client::builder() + .dns_resolver(vec![r1, r2]) + .build(); + assert!(client.is_ok()); + } + + #[test] + fn debug_output() { + let resolver = DohResolver::new("https://cloudflare-dns.com:8443/custom-path").unwrap(); + let debug = format!("{:?}", resolver); + assert!(debug.contains("cloudflare-dns.com"), "{debug}"); + assert!(debug.contains("/custom-path"), "{debug}"); + assert!(debug.contains("8443"), "{debug}"); + } +} diff --git a/crates/primp-reqwest/src/dns/dot.rs b/crates/primp-reqwest/src/dns/dot.rs new file mode 100644 index 0000000..5d46d21 --- /dev/null +++ b/crates/primp-reqwest/src/dns/dot.rs @@ -0,0 +1,145 @@ +//! DNS-over-TLS (DoT) resolution via hickory-resolver + +use hickory_resolver::{ + config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, + name_server::TokioConnectionProvider, + proto::xfer::Protocol, + TokioResolver, +}; + +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use super::{Addrs, Name, Resolve, Resolving, SocketAddrs}; +use super::gai::GaiResolver; +use crate::error::BoxError; + +/// A DNS-over-TLS resolver backed by hickory-resolver. +pub struct DotResolver { + state: Arc>>>, + bootstrap: Arc, + tls_host: String, + tls_port: u16, +} + +impl std::fmt::Debug for DotResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DotResolver") + .field("tls_host", &self.tls_host) + .field("tls_port", &self.tls_port) + .finish() + } +} + +impl Clone for DotResolver { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + bootstrap: self.bootstrap.clone(), + tls_host: self.tls_host.clone(), + tls_port: self.tls_port, + } + } +} + +impl DotResolver { + /// Create a new DoT resolver from a hostname like `"1.1.1.1"` or `"cloudflare-dns.com"`. + /// + /// The host is resolved via the system resolver (GaiResolver) on first lookup. + /// The default port is 853. + pub fn new(host: &str) -> Self { + Self::new_with_port(host, 853) + } + + /// Create a new DoT resolver with a custom port. + pub fn new_with_port(host: &str, port: u16) -> Self { + let bootstrap: Arc = Arc::new(GaiResolver::new()); + Self { + state: Arc::new(Mutex::new(None)), + bootstrap, + tls_host: host.to_string(), + tls_port: port, + } + } + + async fn get_resolver(&self) -> Result, BoxError> { + if let Some(ref resolver) = *self.state.lock().unwrap() { + return Ok(resolver.clone()); + } + + let addrs = self + .bootstrap + .resolve(Name::from_str(&self.tls_host)?) + .await?; + let ips: Vec = addrs.map(|a| a.ip()).collect(); + + let mut group = NameServerConfigGroup::with_capacity(ips.len()); + for &ip in &ips { + group.push(NameServerConfig { + socket_addr: SocketAddr::new(ip, self.tls_port), + protocol: Protocol::Tls, + tls_dns_name: Some(self.tls_host.clone()), + http_endpoint: None, + trust_negative_responses: true, + bind_addr: None, + }); + } + let config = ResolverConfig::from_parts(None, vec![], group); + + let mut builder = + TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); + let opts = builder.options_mut(); + opts.timeout = Duration::from_secs(5); + opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; + let resolver = Arc::new(builder.build()); + + let mut guard = self.state.lock().unwrap(); + if guard.is_none() { + *guard = Some(resolver.clone()); + } + Ok(guard.as_ref().unwrap().clone()) + } +} + +impl Resolve for DotResolver { + fn resolve(&self, name: Name) -> Resolving { + let this = self.clone(); + Box::pin(async move { + let resolver = this.get_resolver().await?; + let lookup = resolver.lookup_ip(name.as_str()).await?; + let addrs: Addrs = Box::new(SocketAddrs { + iter: lookup.into_iter(), + }); + Ok(addrs) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_default_port() { + let resolver = DotResolver::new("1.1.1.1"); + assert_eq!(resolver.tls_host, "1.1.1.1"); + assert_eq!(resolver.tls_port, 853); + } + + #[test] + fn new_custom_port() { + let resolver = DotResolver::new_with_port("dns.google", 5353); + assert_eq!(resolver.tls_host, "dns.google"); + assert_eq!(resolver.tls_port, 5353); + } + + #[test] + fn debug_output() { + let resolver = DotResolver::new_with_port("cloudflare-dns.com", 853); + let debug = format!("{:?}", resolver); + assert!(debug.contains("cloudflare-dns.com"), "{debug}"); + assert!(debug.contains("853"), "{debug}"); + } +} diff --git a/crates/primp-reqwest/src/dns/gai.rs b/crates/primp-reqwest/src/dns/gai.rs index be0895a..5cfb5de 100644 --- a/crates/primp-reqwest/src/dns/gai.rs +++ b/crates/primp-reqwest/src/dns/gai.rs @@ -4,10 +4,12 @@ use tower_service::Service; use crate::dns::{Addrs, Name, Resolve, Resolving}; use crate::error::BoxError; +/// DNS resolver backed by the OS `getaddrinfo` (system resolver). #[derive(Debug)] pub struct GaiResolver(HyperGaiResolver); impl GaiResolver { + /// Create a new system resolver. pub fn new() -> Self { Self(HyperGaiResolver::new()) } diff --git a/crates/primp-reqwest/src/dns/hickory.rs b/crates/primp-reqwest/src/dns/hickory.rs index 7fd670d..82fe215 100644 --- a/crates/primp-reqwest/src/dns/hickory.rs +++ b/crates/primp-reqwest/src/dns/hickory.rs @@ -22,8 +22,8 @@ pub(crate) struct HickoryDnsResolver { state: Arc>, } -struct SocketAddrs { - iter: LookupIpIntoIter, +pub(crate) struct SocketAddrs { + pub(crate) iter: LookupIpIntoIter, } impl Resolve for HickoryDnsResolver { diff --git a/crates/primp-reqwest/src/dns/mod.rs b/crates/primp-reqwest/src/dns/mod.rs index a2280ca..b25ff39 100644 --- a/crates/primp-reqwest/src/dns/mod.rs +++ b/crates/primp-reqwest/src/dns/mod.rs @@ -1,13 +1,22 @@ //! DNS resolution -pub use resolve::{Addrs, Name, Resolve, Resolving}; +pub use resolve::{Addrs, IntoResolve, Name, Resolve, Resolving}; pub(crate) use resolve::{DnsResolverWithOverrides, DynResolver}; -#[cfg(docsrs)] -pub use resolve::IntoResolve; - pub(crate) mod cache; -pub(crate) mod gai; + +/// System DNS resolver backed by the OS's getaddrinfo. +pub mod gai; +/// DNS resolution traits. +pub mod resolve; #[cfg(feature = "hickory-dns")] pub(crate) mod hickory; -pub(crate) mod resolve; +#[cfg(feature = "hickory-dns")] +pub(crate) use hickory::SocketAddrs; + +#[cfg(feature = "hickory-dns")] +pub mod doh; +#[cfg(feature = "hickory-dns")] +pub mod dot; +#[cfg(feature = "hickory-dns")] +pub mod plain; diff --git a/crates/primp-reqwest/src/dns/plain.rs b/crates/primp-reqwest/src/dns/plain.rs new file mode 100644 index 0000000..5b79a33 --- /dev/null +++ b/crates/primp-reqwest/src/dns/plain.rs @@ -0,0 +1,155 @@ +//! Plain DNS (UDP/TCP) resolution via hickory-resolver + +use hickory_resolver::{ + config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, + name_server::TokioConnectionProvider, + proto::xfer::Protocol, + TokioResolver, +}; + +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use super::{Addrs, Name, Resolve, Resolving, SocketAddrs}; +use super::gai::GaiResolver; +use crate::error::BoxError; + +/// A plain DNS (UDP/TCP) resolver backed by hickory-resolver. +/// +/// Queries the specified DNS server directly using standard UDP/TCP on port 53. +pub struct PlainDnsResolver { + state: Arc>>>, + bootstrap: Arc, + dns_host: String, + dns_port: u16, +} + +impl std::fmt::Debug for PlainDnsResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlainDnsResolver") + .field("dns_host", &self.dns_host) + .field("dns_port", &self.dns_port) + .finish() + } +} + +impl Clone for PlainDnsResolver { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + bootstrap: self.bootstrap.clone(), + dns_host: self.dns_host.clone(), + dns_port: self.dns_port, + } + } +} + +impl PlainDnsResolver { + /// Create a new plain DNS resolver for a given host. + /// + /// The host can be an IP address (`"1.1.1.1"`) or a hostname (`"resolver.example.com"`). + /// The default port is 53. + pub fn new(host: &str) -> Self { + Self::new_with_port(host, 53) + } + + /// Create a new plain DNS resolver with a custom port. + pub fn new_with_port(host: &str, port: u16) -> Self { + let bootstrap: Arc = Arc::new(GaiResolver::new()); + Self { + state: Arc::new(Mutex::new(None)), + bootstrap, + dns_host: host.to_string(), + dns_port: port, + } + } + + async fn get_resolver(&self) -> Result, BoxError> { + if let Some(ref resolver) = *self.state.lock().unwrap() { + return Ok(resolver.clone()); + } + + let addrs = self + .bootstrap + .resolve(Name::from_str(&self.dns_host)?) + .await?; + let ips: Vec<_> = addrs.map(|a| a.ip()).collect(); + + let mut group = NameServerConfigGroup::with_capacity(ips.len() * 2); + for &ip in &ips { + group.push(NameServerConfig { + socket_addr: SocketAddr::new(ip, self.dns_port), + protocol: Protocol::Udp, + tls_dns_name: None, + http_endpoint: None, + trust_negative_responses: true, + bind_addr: None, + }); + group.push(NameServerConfig { + socket_addr: SocketAddr::new(ip, self.dns_port), + protocol: Protocol::Tcp, + tls_dns_name: None, + http_endpoint: None, + trust_negative_responses: true, + bind_addr: None, + }); + } + let config = ResolverConfig::from_parts(None, vec![], group); + + let mut builder = + TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); + let opts = builder.options_mut(); + opts.timeout = Duration::from_secs(5); + opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; + let resolver = Arc::new(builder.build()); + + let mut guard = self.state.lock().unwrap(); + if guard.is_none() { + *guard = Some(resolver.clone()); + } + Ok(guard.as_ref().unwrap().clone()) + } +} + +impl Resolve for PlainDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let this = self.clone(); + Box::pin(async move { + let resolver = this.get_resolver().await?; + let lookup = resolver.lookup_ip(name.as_str()).await?; + let addrs: Addrs = Box::new(SocketAddrs { + iter: lookup.into_iter(), + }); + Ok(addrs) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_default_port() { + let resolver = PlainDnsResolver::new("1.1.1.1"); + assert_eq!(resolver.dns_host, "1.1.1.1"); + assert_eq!(resolver.dns_port, 53); + } + + #[test] + fn new_custom_port() { + let resolver = PlainDnsResolver::new_with_port("8.8.8.8", 5353); + assert_eq!(resolver.dns_host, "8.8.8.8"); + assert_eq!(resolver.dns_port, 5353); + } + + #[test] + fn debug_output() { + let resolver = PlainDnsResolver::new_with_port("1.1.1.1", 53); + let debug = format!("{:?}", resolver); + assert!(debug.contains("1.1.1.1"), "{debug}"); + assert!(debug.contains("53"), "{debug}"); + } +} diff --git a/crates/primp-reqwest/src/dns/resolve.rs b/crates/primp-reqwest/src/dns/resolve.rs index ea25bf8..57b3f13 100644 --- a/crates/primp-reqwest/src/dns/resolve.rs +++ b/crates/primp-reqwest/src/dns/resolve.rs @@ -34,7 +34,7 @@ pub trait Resolve: Send + Sync { } /// A name that must be resolved to addresses. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Name(pub(super) HyperName); /// A more general trait implemented for types implementing `Resolve`. @@ -175,6 +175,47 @@ where } } +/// Chains multiple resolvers: tries each in order, returning the first success. +struct ChainedResolver { + resolvers: Vec>, +} + +impl Resolve for ChainedResolver { + fn resolve(&self, name: Name) -> Resolving { + let resolvers = self.resolvers.clone(); + Box::pin(async move { + let mut last_err = None; + for resolver in &resolvers { + match resolver.resolve(name.clone()).await { + Ok(addrs) => return Ok(addrs), + Err(e) => last_err = Some(e), + } + } + Err(last_err.unwrap_or_else(|| "all DNS resolvers failed".into())) + }) + } +} + +impl IntoResolve for Vec { + fn into_resolve(self) -> Arc { + if self.len() == 1 { + return Arc::new(self.into_iter().next().unwrap()); + } + Arc::new(ChainedResolver { + resolvers: self.into_iter().map(|r| Arc::new(r) as Arc).collect(), + }) + } +} + +impl IntoResolve for Vec> { + fn into_resolve(self) -> Arc { + if self.len() == 1 { + return self.into_iter().next().unwrap(); + } + Arc::new(ChainedResolver { resolvers: self }) + } +} + mod sealed { use std::fmt; diff --git a/crates/primp/src/lib.rs b/crates/primp/src/lib.rs index 5762876..902213c 100644 --- a/crates/primp/src/lib.rs +++ b/crates/primp/src/lib.rs @@ -444,6 +444,20 @@ impl ClientBuilder { self.inner = self.inner.resolve_to_addrs(domain, addrs); self } + + /// Override the DNS resolver implementation. + /// + /// Accepts any type implementing `IntoResolve`, such as `GaiResolver`, + /// or a `Vec` of resolvers for fallback. + /// + /// **Note:** `DohResolver`, `DotResolver`, and `PlainDnsResolver` require the `hickory-dns` feature. + pub fn dns_resolver(mut self, resolver: R) -> Self + where + R: crate::dns::IntoResolve, + { + self.inner = self.inner.dns_resolver(resolver); + self + } } impl Client { From d3e556c6841f76d7c43e93728ab24f1fc89460dd Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Tue, 19 May 2026 09:54:19 +0300 Subject: [PATCH 3/6] chore(reqwest): upgrade hickory-resolver 0.25.2 -> 0.26.1 (fixes NSEC3 DoS) --- Cargo.lock | 128 ++++++++++++++-------- crates/primp-python/src/client_builder.rs | 4 +- crates/primp-reqwest/Cargo.toml | 2 +- crates/primp-reqwest/src/dns/doh.rs | 37 +++---- crates/primp-reqwest/src/dns/dot.rs | 31 ++---- crates/primp-reqwest/src/dns/hickory.rs | 16 +-- crates/primp-reqwest/src/dns/plain.rs | 39 ++----- 7 files changed, 133 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b1e446..cd821e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive 0.6.0", "asn1-rs-impl", @@ -591,7 +591,7 @@ version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs 0.7.1", + "asn1-rs 0.7.2", "displaydoc", "nom", "num-bigint", @@ -655,18 +655,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "env_logger" version = "0.10.2" @@ -998,26 +986,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hickory-proto" -version = "0.25.2" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ "async-trait", "bytes", "cfg-if", "data-encoding", - "enum-as-inner", "futures-channel", "futures-io", "futures-util", "h2", + "hickory-proto", "http", "idna", "ipnet", - "once_cell", - "rand 0.9.4", - "ring", + "jni", + "rand 0.10.1", "rustls", "thiserror 2.0.18", "tinyvec", @@ -1025,31 +1012,56 @@ dependencies = [ "tokio-rustls", "tracing", "url", - "webpki-roots 0.26.11", + "webpki-roots", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", ] [[package]] name = "hickory-resolver" -version = "0.25.2" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" dependencies = [ "cfg-if", "futures-util", + "hickory-net", "hickory-proto", "ipconfig", + "ipnet", + "jni", "moka", + "ndk-context", "once_cell", "parking_lot", - "rand 0.9.4", + "rand 0.10.1", "resolv-conf", "rustls", "smallvec", + "system-configuration 0.7.0", "thiserror 2.0.18", "tokio", "tokio-rustls", "tracing", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] @@ -1324,6 +1336,9 @@ name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "ipnetwork" @@ -1614,6 +1629,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1657,9 +1678,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1705,7 +1726,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs 0.7.1", + "asn1-rs 0.7.2", ] [[package]] @@ -1989,6 +2010,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_env_logger" version = "0.5.0" @@ -2096,7 +2128,7 @@ dependencies = [ "rustls-platform-verifier", "tokio", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -2119,7 +2151,7 @@ dependencies = [ "pretty_env_logger", "primp-hyper", "socket2", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tokio-test", "tower-layer", @@ -2212,7 +2244,7 @@ dependencies = [ "wasm-bindgen-test", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots", "zstd", ] @@ -2242,7 +2274,7 @@ dependencies = [ "serde_json", "subtle", "time", - "webpki-roots 1.0.7", + "webpki-roots", "x509-parser 0.16.0", "zeroize", "zlib-rs", @@ -2259,7 +2291,7 @@ dependencies = [ "primp-rustls", "rcgen 0.14.8", "tokio", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -3041,6 +3073,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -3303,9 +3346,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", "bitflags", @@ -3698,15 +3741,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - [[package]] name = "webpki-roots" version = "1.0.7" @@ -4067,7 +4101,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs 0.7.1", + "asn1-rs 0.7.2", "data-encoding", "der-parser 10.0.0", "lazy_static", diff --git a/crates/primp-python/src/client_builder.rs b/crates/primp-python/src/client_builder.rs index af8cf6f..af8946a 100644 --- a/crates/primp-python/src/client_builder.rs +++ b/crates/primp-python/src/client_builder.rs @@ -46,9 +46,7 @@ fn parse_single_resolver(s: &str) -> PrimpResult> { } else { let host = s.strip_prefix("dns://").unwrap_or(s); if host.is_empty() { - return Err(PrimpErrorEnum::Custom( - "dns:// URL must have a host".into(), - )); + return Err(PrimpErrorEnum::Custom("dns:// URL must have a host".into())); } if host == "system" { return Ok(Arc::new(primp::dns::gai::GaiResolver::new())); diff --git a/crates/primp-reqwest/Cargo.toml b/crates/primp-reqwest/Cargo.toml index b744b0e..6c67930 100644 --- a/crates/primp-reqwest/Cargo.toml +++ b/crates/primp-reqwest/Cargo.toml @@ -161,7 +161,7 @@ cookie_store = { version = "0.22.0", optional = true } tokio-util = { version = "0.7.9", default-features = false, features = ["io"], optional = true } ## hickory-dns -hickory-resolver = { version = "0.25", optional = true, features = ["tokio"] } +hickory-resolver = { version = "0.26", optional = true, features = ["tokio", "system-config"] } once_cell = { version = "1.18", optional = true } # HTTP/3 experimental support diff --git a/crates/primp-reqwest/src/dns/doh.rs b/crates/primp-reqwest/src/dns/doh.rs index da312e8..9e98bee 100644 --- a/crates/primp-reqwest/src/dns/doh.rs +++ b/crates/primp-reqwest/src/dns/doh.rs @@ -1,13 +1,12 @@ //! DNS-over-HTTPS (DoH) resolution via hickory-resolver use hickory_resolver::{ - config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, - name_server::TokioConnectionProvider, - proto::xfer::Protocol, + config::{LookupIpStrategy, NameServerConfig, ResolverConfig}, + net::runtime::TokioRuntimeProvider, TokioResolver, }; -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -79,25 +78,24 @@ impl DohResolver { .await?; let ips: Vec = addrs.map(|a| a.ip()).collect(); - let mut group = NameServerConfigGroup::with_capacity(ips.len()); - for &ip in &ips { - group.push(NameServerConfig { - socket_addr: SocketAddr::new(ip, self.doh_port), - protocol: Protocol::Https, - tls_dns_name: Some(self.doh_host.clone()), - http_endpoint: Some(self.doh_path.clone()), - trust_negative_responses: true, - bind_addr: None, - }); - } - let config = ResolverConfig::from_parts(None, vec![], group); + let name_servers: Vec = ips + .iter() + .map(|&ip| { + NameServerConfig::https( + ip, + self.doh_host.clone().into(), + Some(self.doh_path.clone().into()), + ) + }) + .collect(); + let config = ResolverConfig::from_parts(None, vec![], name_servers); let mut builder = - TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); + TokioResolver::builder_with_config(config, TokioRuntimeProvider::default()); let opts = builder.options_mut(); opts.timeout = Duration::from_secs(5); opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - let resolver = Arc::new(builder.build()); + let resolver = Arc::new(builder.build().expect("failed to build DoH resolver")); let mut guard = self.state.lock().unwrap(); if guard.is_none() { @@ -113,8 +111,9 @@ impl Resolve for DohResolver { Box::pin(async move { let resolver = this.get_resolver().await?; let lookup = resolver.lookup_ip(name.as_str()).await?; + let ips: Vec = lookup.iter().collect(); let addrs: Addrs = Box::new(SocketAddrs { - iter: lookup.into_iter(), + iter: ips.into_iter(), }); Ok(addrs) }) diff --git a/crates/primp-reqwest/src/dns/dot.rs b/crates/primp-reqwest/src/dns/dot.rs index 5d46d21..ee57219 100644 --- a/crates/primp-reqwest/src/dns/dot.rs +++ b/crates/primp-reqwest/src/dns/dot.rs @@ -1,13 +1,12 @@ //! DNS-over-TLS (DoT) resolution via hickory-resolver use hickory_resolver::{ - config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, - name_server::TokioConnectionProvider, - proto::xfer::Protocol, + config::{LookupIpStrategy, NameServerConfig, ResolverConfig}, + net::runtime::TokioRuntimeProvider, TokioResolver, }; -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -75,25 +74,18 @@ impl DotResolver { .await?; let ips: Vec = addrs.map(|a| a.ip()).collect(); - let mut group = NameServerConfigGroup::with_capacity(ips.len()); - for &ip in &ips { - group.push(NameServerConfig { - socket_addr: SocketAddr::new(ip, self.tls_port), - protocol: Protocol::Tls, - tls_dns_name: Some(self.tls_host.clone()), - http_endpoint: None, - trust_negative_responses: true, - bind_addr: None, - }); - } - let config = ResolverConfig::from_parts(None, vec![], group); + let name_servers: Vec = ips + .iter() + .map(|&ip| NameServerConfig::tls(ip, self.tls_host.clone().into())) + .collect(); + let config = ResolverConfig::from_parts(None, vec![], name_servers); let mut builder = - TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); + TokioResolver::builder_with_config(config, TokioRuntimeProvider::default()); let opts = builder.options_mut(); opts.timeout = Duration::from_secs(5); opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - let resolver = Arc::new(builder.build()); + let resolver = Arc::new(builder.build().expect("failed to build DoT resolver")); let mut guard = self.state.lock().unwrap(); if guard.is_none() { @@ -109,8 +101,9 @@ impl Resolve for DotResolver { Box::pin(async move { let resolver = this.get_resolver().await?; let lookup = resolver.lookup_ip(name.as_str()).await?; + let ips: Vec = lookup.iter().collect(); let addrs: Addrs = Box::new(SocketAddrs { - iter: lookup.into_iter(), + iter: ips.into_iter(), }); Ok(addrs) }) diff --git a/crates/primp-reqwest/src/dns/hickory.rs b/crates/primp-reqwest/src/dns/hickory.rs index 82fe215..59afca8 100644 --- a/crates/primp-reqwest/src/dns/hickory.rs +++ b/crates/primp-reqwest/src/dns/hickory.rs @@ -2,13 +2,12 @@ use hickory_resolver::{ config::{LookupIpStrategy, ResolverConfig}, - lookup_ip::LookupIpIntoIter, - name_server::TokioConnectionProvider, + net::runtime::TokioRuntimeProvider, TokioResolver, }; use once_cell::sync::OnceCell; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use super::{Addrs, Name, Resolve, Resolving}; @@ -23,7 +22,7 @@ pub(crate) struct HickoryDnsResolver { } pub(crate) struct SocketAddrs { - pub(crate) iter: LookupIpIntoIter, + pub(crate) iter: std::vec::IntoIter, } impl Resolve for HickoryDnsResolver { @@ -33,8 +32,9 @@ impl Resolve for HickoryDnsResolver { let resolver = resolver.state.get_or_init(new_resolver); let lookup = resolver.lookup_ip(name.as_str()).await?; + let ips: Vec = lookup.iter().collect(); let addrs: Addrs = Box::new(SocketAddrs { - iter: lookup.into_iter(), + iter: ips.into_iter(), }); Ok(addrs) }) @@ -45,7 +45,7 @@ impl Iterator for SocketAddrs { type Item = SocketAddr; fn next(&mut self) -> Option { - self.iter.next().map(|ip_addr| SocketAddr::new(ip_addr, 0)) + self.iter.next().map(|ip| SocketAddr::new(ip, 0)) } } @@ -62,9 +62,9 @@ fn new_resolver() -> TokioResolver { ); TokioResolver::builder_with_config( ResolverConfig::default(), - TokioConnectionProvider::default(), + TokioRuntimeProvider::default(), ) }); builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - builder.build() + builder.build().expect("failed to build hickory resolver") } diff --git a/crates/primp-reqwest/src/dns/plain.rs b/crates/primp-reqwest/src/dns/plain.rs index 5b79a33..c464741 100644 --- a/crates/primp-reqwest/src/dns/plain.rs +++ b/crates/primp-reqwest/src/dns/plain.rs @@ -1,13 +1,12 @@ //! Plain DNS (UDP/TCP) resolution via hickory-resolver use hickory_resolver::{ - config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, - name_server::TokioConnectionProvider, - proto::xfer::Protocol, + config::{LookupIpStrategy, NameServerConfig, ResolverConfig}, + net::runtime::TokioRuntimeProvider, TokioResolver, }; -use std::net::SocketAddr; +use std::net::IpAddr; use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -77,33 +76,18 @@ impl PlainDnsResolver { .await?; let ips: Vec<_> = addrs.map(|a| a.ip()).collect(); - let mut group = NameServerConfigGroup::with_capacity(ips.len() * 2); - for &ip in &ips { - group.push(NameServerConfig { - socket_addr: SocketAddr::new(ip, self.dns_port), - protocol: Protocol::Udp, - tls_dns_name: None, - http_endpoint: None, - trust_negative_responses: true, - bind_addr: None, - }); - group.push(NameServerConfig { - socket_addr: SocketAddr::new(ip, self.dns_port), - protocol: Protocol::Tcp, - tls_dns_name: None, - http_endpoint: None, - trust_negative_responses: true, - bind_addr: None, - }); - } - let config = ResolverConfig::from_parts(None, vec![], group); + let name_servers: Vec = ips + .iter() + .map(|&ip| NameServerConfig::udp_and_tcp(ip)) + .collect(); + let config = ResolverConfig::from_parts(None, vec![], name_servers); let mut builder = - TokioResolver::builder_with_config(config, TokioConnectionProvider::default()); + TokioResolver::builder_with_config(config, TokioRuntimeProvider::default()); let opts = builder.options_mut(); opts.timeout = Duration::from_secs(5); opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - let resolver = Arc::new(builder.build()); + let resolver = Arc::new(builder.build().expect("failed to build plain DNS resolver")); let mut guard = self.state.lock().unwrap(); if guard.is_none() { @@ -119,8 +103,9 @@ impl Resolve for PlainDnsResolver { Box::pin(async move { let resolver = this.get_resolver().await?; let lookup = resolver.lookup_ip(name.as_str()).await?; + let ips: Vec = lookup.iter().collect(); let addrs: Addrs = Box::new(SocketAddrs { - iter: lookup.into_iter(), + iter: ips.into_iter(), }); Ok(addrs) }) From 8683d4b80cef1c76a63e3f9949c46888b3d284bc Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Tue, 19 May 2026 09:54:19 +0300 Subject: [PATCH 4/6] fix(rustls): remove duplicate 'Browser emulation' debug log in ClientHello emission --- crates/primp-rustls/rustls/src/client/hs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/primp-rustls/rustls/src/client/hs.rs b/crates/primp-rustls/rustls/src/client/hs.rs index 17653ad..8402d5b 100644 --- a/crates/primp-rustls/rustls/src/client/hs.rs +++ b/crates/primp-rustls/rustls/src/client/hs.rs @@ -698,7 +698,6 @@ fn emit_client_hello_for_retry( // Debug: Print cipher suites being used #[cfg(all(feature = "logging", feature = "impersonate"))] { - debug!("Browser emulation: {:?}", config.browser_emulation); debug!("Cipher suites count: {}", cipher_suites.len()); for (i, cs) in cipher_suites.iter().enumerate() { debug!(" [{}] {:?}", i, cs); From 18333048f3e52d1fb670dae3e83669e1f1f10913 Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Tue, 19 May 2026 09:54:19 +0300 Subject: [PATCH 5/6] fix(reqwest): add per-query timeout to DohResolver to prevent hang and enable ChainedResolver fallback --- crates/primp-reqwest/src/dns/doh.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/primp-reqwest/src/dns/doh.rs b/crates/primp-reqwest/src/dns/doh.rs index 9e98bee..bd5dd16 100644 --- a/crates/primp-reqwest/src/dns/doh.rs +++ b/crates/primp-reqwest/src/dns/doh.rs @@ -1,10 +1,8 @@ //! DNS-over-HTTPS (DoH) resolution via hickory-resolver -use hickory_resolver::{ - config::{LookupIpStrategy, NameServerConfig, ResolverConfig}, - net::runtime::TokioRuntimeProvider, - TokioResolver, -}; +use hickory_resolver::config::{LookupIpStrategy, NameServerConfig, ResolverConfig}; +use hickory_resolver::net::runtime::TokioRuntimeProvider; +use hickory_resolver::TokioResolver; use std::net::IpAddr; use std::str::FromStr; @@ -110,7 +108,10 @@ impl Resolve for DohResolver { let this = self.clone(); Box::pin(async move { let resolver = this.get_resolver().await?; - let lookup = resolver.lookup_ip(name.as_str()).await?; + let lookup = tokio::time::timeout(Duration::from_secs(5), resolver.lookup_ip(name.as_str())) + .await + .map_err(|_| BoxError::from("DoH lookup timed out"))? + .map_err(BoxError::from)?; let ips: Vec = lookup.iter().collect(); let addrs: Addrs = Box::new(SocketAddrs { iter: ips.into_iter(), From 925812c3abb79dcb4375aaab0d151e829c149aa4 Mon Sep 17 00:00:00 2001 From: deedy5 <65482418+deedy5@users.noreply.github.com> Date: Tue, 19 May 2026 10:10:24 +0300 Subject: [PATCH 6/6] fix(py): include error source chain in connect/timeout/request exception messages --- crates/primp-python/src/error.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/primp-python/src/error.rs b/crates/primp-python/src/error.rs index 57cf1a8..105862c 100644 --- a/crates/primp-python/src/error.rs +++ b/crates/primp-python/src/error.rs @@ -22,6 +22,7 @@ use pyo3::create_exception; use pyo3::exceptions::PyException; use pyo3::PyErr; +use std::error::Error; use std::fmt; use std::io; @@ -177,6 +178,21 @@ pub type PrimpResult = std::result::Result; // Error Conversion Functions // ============================================================================= +/// Format an error with its full source chain for better debugging. +fn format_with_source(err: &::primp::Error) -> String { + let mut msg = err.to_string(); + let mut source: Option<&(dyn Error + 'static)> = err.source(); + while let Some(s) = source { + let s_msg: String = s.to_string(); + if !msg.contains(&s_msg) { + msg.push_str(" > "); + msg.push_str(&s_msg); + } + source = s.source(); + } + msg +} + /// Convert a ::primp::Error to the appropriate Python exception. /// /// Uses native type-based detection via `is_*()` methods from primp-reqwest. @@ -196,39 +212,44 @@ pub type PrimpResult = std::result::Result; /// 10. Fallback → PrimpError pub(crate) fn convert_reqwest_error(err: ::primp::Error) -> PyErr { let url = err.url().map(|u| u.to_string()); - let message = err.to_string(); // Use native primp-reqwest error API - NO message parsing! // Builder errors (includes URL and header errors) if err.is_builder() { + let message = err.to_string(); return BuilderError::new_err((message, url)); } // Status errors (HTTP 4xx/5xx) if err.is_status() { + let message = err.to_string(); let status_code = err.status().map(|s| s.as_u16()).unwrap_or(0); return StatusError::new_err((status_code, message, url)); } // Redirect errors if err.is_redirect() { + let message = err.to_string(); return RedirectError::new_err((message, url)); } + // Include source chain for request-level errors (connect, timeout, generic) + let message = format_with_source(&err); + // Timeout errors (child of RequestError) if err.is_timeout() { - return TimeoutError::new_err((message, url)); + return TimeoutError::new_err(message); } // Connect errors (child of RequestError) if err.is_connect() { - return ConnectError::new_err((message, url)); + return ConnectError::new_err(message); } // Request errors (generic) if err.is_request() { - return RequestError::new_err((message, url)); + return RequestError::new_err(message); } // Decode errors