This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs-object-store.git


The following commit(s) were added to refs/heads/main by this push:
     new 699c4fc  feat: Support custom DNS resolver (#728)
699c4fc is described below

commit 699c4fc675df8bc8d8fbd756c4cc426186513f97
Author: Koen Denecker <[email protected]>
AuthorDate: Wed Aug 12 17:18:31 2026 +0200

    feat: Support custom DNS resolver (#728)
    
    * feat: Support custom DNS resolver
    
    * add local `DnsResolver` trait
    
    * refactor for wasm32 feature and reqwest as optional
---
 src/client/dns.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++---------
 src/client/mod.rs |  79 +++++++++++++++++++++++++++--
 2 files changed, 200 insertions(+), 27 deletions(-)

diff --git a/src/client/dns.rs b/src/client/dns.rs
index b1abe59..7a4cc85 100644
--- a/src/client/dns.rs
+++ b/src/client/dns.rs
@@ -15,36 +15,136 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use std::net::ToSocketAddrs;
+//! Customizable DNS resolution for remote object stores
 
-use rand::prelude::SliceRandom;
-use reqwest::dns::{Addrs, Name, Resolve, Resolving};
-use tokio::task::JoinSet;
+use std::fmt::Debug;
+use std::future::Future;
+use std::net::IpAddr;
+use std::pin::Pin;
 
-type DynErr = Box<dyn std::error::Error + Send + Sync>;
+/// Error returned by a [`DnsResolver`]
+pub type DnsError = Box<dyn std::error::Error + Send + Sync>;
 
-#[derive(Debug)]
-pub(crate) struct ShuffleResolver;
+/// Future returned by [`DnsResolver::resolve`]
+// NOTE: the use cases requiring SocketAddr over IpAddr (i.e., 
resolver-supplied ports
+// and IPv6 scope IDs) do not apply to object_store, where the port is always
+// determined by the endpoint URL/scheme and endpoints are never link-local.
+pub type DnsFuture = Pin<Box<dyn Future<Output = Result<Vec<IpAddr>, 
DnsError>> + Send>>;
 
-impl Resolve for ShuffleResolver {
-    fn resolve(&self, name: Name) -> Resolving {
-        Box::pin(async move {
-            // use `JoinSet` to propagate cancelation to tasks that haven't 
started running yet.
-            let mut tasks = JoinSet::new();
-            tasks.spawn_blocking(move || {
-                let it = (name.as_str(), 0).to_socket_addrs()?;
-                let mut addrs = it.collect::<Vec<_>>();
+/// A custom DNS resolver used when establishing connections to remote object 
stores
+///
+/// This can be used to implement custom resolution logic such as caching,
+/// address shuffling, or split-horizon DNS, independent of the underlying
+/// HTTP transport.
+///
+/// Configure via [`ClientOptions::with_dns_resolver`]. The built-in
+/// reqwest-based transport honors this automatically; custom
+/// [`HttpConnector`] implementations should retrieve it via
+/// [`ClientOptions::dns_resolver`] and apply it themselves.
+///
+/// [`ClientOptions::with_dns_resolver`]: 
crate::ClientOptions::with_dns_resolver
+/// [`ClientOptions::dns_resolver`]: crate::ClientOptions::dns_resolver
+/// [`HttpConnector`]: crate::client::HttpConnector
+pub trait DnsResolver: Debug + Send + Sync {
+    /// Resolve `host` to one or more IP addresses
+    ///
+    /// The returned addresses are tried in order until a connection succeeds,
+    /// so implementations are responsible for any ordering they require, e.g.
+    /// shuffling or interleaving of address families. The port is determined
+    /// by the transport from the URL, not by the resolver.
+    fn resolve(&self, host: &str) -> DnsFuture;
+}
+
+#[cfg(feature = "reqwest")]
+mod reqwest_impl {
+    use super::{DnsError, DnsFuture, DnsResolver};
+    use rand::prelude::SliceRandom;
+    use std::net::{SocketAddr, ToSocketAddrs};
+    use std::sync::Arc;
+    use tokio::task::JoinSet;
+
+    /// Adapts a [`DnsResolver`] to [`reqwest::dns::Resolve`]
+    ///
+    /// This is deliberately private: it is the only place where [`reqwest`]'s
+    /// resolver API appears, keeping it out of this crate's public interface.
+    pub(crate) struct ReqwestResolver(pub(crate) Arc<dyn DnsResolver>);
+
+    impl reqwest::dns::Resolve for ReqwestResolver {
+        fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving 
{
+            let resolver = Arc::clone(&self.0);
+            let host = name.as_str().to_string();
+            Box::pin(async move {
+                let ips = resolver.resolve(&host).await?;
+                // Port 0 is a placeholder: reqwest documents that it is 
replaced
+                // by the port from the URL or the scheme's conventional port
+                let addrs: reqwest::dns::Addrs =
+                    Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
+                Ok(addrs)
+            })
+        }
+    }
+
+    /// The built-in shuffling [`DnsResolver`], randomizing the order of the 
returned
+    /// addresses to spread load across servers, see 
[`ClientConfigKey::RandomizeAddresses`]
+    ///
+    /// [`ClientConfigKey::RandomizeAddresses`]: 
crate::ClientConfigKey::RandomizeAddresses
+    #[derive(Debug)]
+    pub(crate) struct ShuffleResolver;
+
+    impl DnsResolver for ShuffleResolver {
+        fn resolve(&self, host: &str) -> DnsFuture {
+            let host = host.to_string();
+            Box::pin(async move {
+                // use `JoinSet` to propagate cancellation to tasks that 
haven't started running yet.
+                let mut tasks = JoinSet::new();
+                tasks.spawn_blocking(move || {
+                    let it = (host.as_str(), 0).to_socket_addrs()?;
+                    let mut addrs = it.map(|addr| 
addr.ip()).collect::<Vec<_>>();
+                    addrs.shuffle(&mut rand::rng());
+                    Ok(addrs)
+                });
+                tasks
+                    .join_next()
+                    .await
+                    .expect("spawned one task")
+                    .map_err(|err| Box::new(err) as DnsError)?
+            })
+        }
+    }
+}
 
-                addrs.shuffle(&mut rand::rng());
+#[cfg(feature = "reqwest")]
+pub(crate) use reqwest_impl::{ReqwestResolver, ShuffleResolver};
 
-                Ok(Box::new(addrs.into_iter()) as Addrs)
-            });
+#[cfg(all(test, feature = "reqwest"))]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn shuffle_resolver_resolves_localhost() {
+        let ips = ShuffleResolver.resolve("localhost").await.unwrap();
+        assert!(!ips.is_empty());
+        assert!(ips.iter().all(|ip| ip.is_loopback()));
+    }
+
+    #[derive(Debug)]
+    struct FailingResolver;
+
+    impl DnsResolver for FailingResolver {
+        fn resolve(&self, _host: &str) -> DnsFuture {
+            Box::pin(async { Err("boom".into()) })
+        }
+    }
 
-            tasks
-                .join_next()
-                .await
-                .expect("spawned on task")
-                .map_err(|err| Box::new(err) as DynErr)?
-        })
+    #[tokio::test]
+    async fn adapter_propagates_errors() {
+        use reqwest::dns::Resolve;
+        use std::sync::Arc;
+        let adapter = ReqwestResolver(Arc::new(FailingResolver));
+        let err = match adapter.resolve("localhost".parse().unwrap()).await {
+            Ok(_) => panic!("expected resolution to fail"),
+            Err(e) => e,
+        };
+        assert!(err.to_string().contains("boom"));
     }
 }
diff --git a/src/client/mod.rs b/src/client/mod.rs
index a8d2ae5..dff11b9 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -21,8 +21,10 @@
 
 pub(crate) mod backoff;
 
-#[cfg(all(feature = "reqwest", not(target_arch = "wasm32")))]
+#[cfg(not(target_arch = "wasm32"))]
 mod dns;
+#[cfg(not(target_arch = "wasm32"))]
+pub use dns::{DnsError, DnsFuture, DnsResolver};
 
 #[cfg(not(target_arch = "wasm32"))]
 #[cfg(test)]
@@ -363,6 +365,8 @@ pub struct ClientOptions {
     http1_only: ConfigValue<bool>,
     http2_only: ConfigValue<bool>,
     randomize_addresses: ConfigValue<bool>,
+    #[cfg(not(target_arch = "wasm32"))]
+    dns_resolver: Option<Arc<dyn DnsResolver>>,
 }
 
 impl Default for ClientOptions {
@@ -402,6 +406,8 @@ impl Default for ClientOptions {
             http1_only: true.into(),
             http2_only: Default::default(),
             randomize_addresses: true.into(),
+            #[cfg(not(target_arch = "wasm32"))]
+            dns_resolver: Default::default(),
         }
     }
 }
@@ -796,6 +802,32 @@ impl ClientOptions {
         self
     }
 
+    /// Override the default DNS resolution with a custom [`DnsResolver`]
+    ///
+    /// When set, [`ClientConfigKey::RandomizeAddresses`] is ignored: the
+    /// provided resolver is fully responsible for resolution, ordering,
+    /// shuffling, and caching.
+    ///
+    /// The built-in reqwest-based transport applies this automatically.
+    /// Custom [`HttpConnector`] implementations should read it via
+    /// [`Self::dns_resolver`] and honor it.
+    ///
+    /// Note: unlike other options, this cannot be configured via
+    /// [`ClientConfigKey`] / string configuration.
+    ///
+    /// [`HttpConnector`]: crate::client::HttpConnector
+    #[cfg(not(target_arch = "wasm32"))]
+    pub fn with_dns_resolver(mut self, resolver: Arc<dyn DnsResolver>) -> Self 
{
+        self.dns_resolver = Some(resolver);
+        self
+    }
+
+    /// Return the custom [`DnsResolver`] if any, see 
[`Self::with_dns_resolver`]
+    #[cfg(not(target_arch = "wasm32"))]
+    pub fn dns_resolver(&self) -> Option<&Arc<dyn DnsResolver>> {
+        self.dns_resolver.as_ref()
+    }
+
     /// Get the default headers defined through 
`ClientOptions::with_default_headers`
     pub fn get_default_headers(&self) -> Option<&HeaderMap> {
         self.default_headers.as_ref()
@@ -928,8 +960,13 @@ impl ClientOptions {
         // size of objects.
         builder = builder.no_gzip().no_brotli().no_zstd().no_deflate();
 
-        if self.randomize_addresses.get()? {
-            builder = builder.dns_resolver(Arc::new(dns::ShuffleResolver));
+        let resolver: Option<Arc<dyn DnsResolver>> = match &self.dns_resolver {
+            Some(resolver) => Some(Arc::clone(resolver)),
+            None if self.randomize_addresses.get()? => 
Some(Arc::new(dns::ShuffleResolver)),
+            None => None,
+        };
+        if let Some(resolver) = resolver {
+            builder = 
builder.dns_resolver(Arc::new(dns::ReqwestResolver(resolver)));
         }
 
         builder
@@ -1263,4 +1300,40 @@ mod tests {
             user_agent
         );
     }
+
+    #[tokio::test]
+    #[cfg(all(feature = "reqwest", not(target_arch = "wasm32")))]
+    async fn test_custom_dns_resolver() {
+        use crate::client::mock_server::MockServer;
+        use std::net::{IpAddr, Ipv4Addr};
+        use std::sync::atomic::{AtomicUsize, Ordering};
+
+        #[derive(Debug, Default)]
+        struct CountingResolver(AtomicUsize);
+
+        impl DnsResolver for CountingResolver {
+            fn resolve(&self, host: &str) -> DnsFuture {
+                assert_eq!(host, "localhost");
+                self.0.fetch_add(1, Ordering::SeqCst);
+                Box::pin(async { Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]) })
+            }
+        }
+
+        let server = MockServer::new().await;
+        server.push(::http::Response::new("hello".to_string()));
+
+        let resolver = Arc::new(CountingResolver::default());
+        let client = ClientOptions::new()
+            .with_allow_http(true)
+            .with_dns_resolver(Arc::clone(&resolver) as Arc<dyn DnsResolver>)
+            .client()
+            .unwrap();
+
+        // Use a hostname (not an IP literal) so resolution actually runs
+        let parsed = url::Url::parse(server.url()).unwrap();
+        let url = format!("http://localhost:{}/";, parsed.port().unwrap());
+        let resp = client.get(url).send().await.unwrap();
+        assert!(resp.status().is_success());
+        assert_eq!(resolver.0.load(Ordering::SeqCst), 1);
+    }
 }

Reply via email to