use anyhow::{anyhow, Context, Result};
use once_cell::sync::Lazy;
use reqwest::{Client, Url};
use std::{
collections::HashMap,
net::{SocketAddr, ToSocketAddrs},
sync::Mutex,
};
use tracing::{debug, warn};
static CLIENTS: Lazy<Mutex<HashMap<HttpClientKey, Client>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct HttpClientKey {
scheme: String,
host: String,
port: u16,
}
impl HttpClientKey {
fn from_url(url: &str) -> Result<Self> {
let parsed = Url::parse(url)
.with_context(|| format!("Failed to parse base URL for HTTP client: {url}"))?;
let host = parsed
.host_str()
.ok_or_else(|| anyhow!("Base URL is missing host: {url}"))?;
let scheme = parsed.scheme();
let port = parsed.port_or_known_default().ok_or_else(|| {
anyhow!(
"Unable to determine port for {}://{} – specify an explicit port",
scheme,
host
)
})?;
Ok(Self {
scheme: scheme.to_string(),
host: host.to_string(),
port,
})
}
fn host(&self) -> &str {
&self.host
}
}
pub struct HttpClientContext<'a> {
key: &'a HttpClientKey,
resolved_addrs: Option<Vec<SocketAddr>>,
}
impl<'a> HttpClientContext<'a> {
pub fn scheme(&self) -> &str {
&self.key.scheme
}
pub fn host(&self) -> &str {
self.key.host()
}
pub fn port(&self) -> u16 {
self.key.port
}
pub fn resolved_addrs(&self) -> Option<&[SocketAddr]> {
self.resolved_addrs.as_deref()
}
}
pub fn get_or_init_client<F>(base_url: &str, builder: F) -> Result<Client>
where
F: FnOnce(&HttpClientContext) -> Result<Client>,
{
let key = HttpClientKey::from_url(base_url)?;
if let Some(existing) = CLIENTS.lock().unwrap().get(&key).cloned() {
return Ok(existing);
}
let resolved = maybe_resolve_host(&key);
let context = HttpClientContext {
key: &key,
resolved_addrs: resolved,
};
let client = builder(&context)?;
let mut guard = CLIENTS.lock().unwrap();
let entry = guard.entry(key).or_insert_with(|| client.clone());
Ok(entry.clone())
}
fn maybe_resolve_host(key: &HttpClientKey) -> Option<Vec<SocketAddr>> {
let target = format!("{}:{}", key.host(), key.port);
match target.to_socket_addrs() {
Ok(iter) => {
let addrs: Vec<_> = iter.collect();
if addrs.is_empty() {
warn!(
host = key.host(),
scheme = key.scheme,
port = key.port,
"DNS lookup returned no addresses; falling back to runtime resolution"
);
None
} else {
debug!(
host = key.host(),
scheme = key.scheme,
port = key.port,
addr_count = addrs.len(),
"Cached DNS resolution succeeded"
);
Some(addrs)
}
}
Err(err) => {
warn!(
host = key.host(),
scheme = key.scheme,
port = key.port,
error = %err,
"Failed to pre-resolve DNS; falling back to runtime resolution"
);
None
}
}
}