use std::collections::HashMap;
use std::sync::LazyLock;
use std::thread;
use std::time::{Duration, Instant};
use crate::error::BioLibError;
static SHARED_CLIENT: LazyLock<HttpClient> = LazyLock::new(|| {
let config = crate::Config::load();
HttpClient::from_config(&config)
});
#[derive(Clone)]
pub struct HttpClient {
agent: ureq::Agent,
}
impl HttpClient {
pub fn shared() -> Self {
SHARED_CLIENT.clone()
}
fn from_config(_config: &crate::Config) -> Self {
#[allow(unused_mut)]
let mut builder = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(180)))
.http_status_as_error(false);
#[cfg(any(feature = "native-tls", feature = "rustls"))]
{
#[allow(unused_mut)]
let mut tls_builder = ureq::tls::TlsConfig::builder();
#[cfg(all(feature = "native-tls", not(feature = "rustls")))]
{
tls_builder = tls_builder.provider(ureq::tls::TlsProvider::NativeTls);
}
if let Some(ref path) = _config.ca_bundle {
match std::fs::read(path) {
Ok(pem_data) => {
let certs: Vec<ureq::tls::Certificate<'static>> =
ureq::tls::parse_pem(&pem_data)
.filter_map(|item| match item {
Ok(ureq::tls::PemItem::Certificate(cert)) => Some(cert),
_ => None,
})
.collect();
if !certs.is_empty() {
tls_builder = tls_builder
.root_certs(ureq::tls::RootCerts::new_with_certs(&certs));
}
}
Err(err) => {
crate::logging::error(&format!(
"Failed to read CA bundle from {path}: {err}"
));
}
}
}
builder = builder.tls_config(tls_builder.build());
}
let agent = builder.build().new_agent();
Self { agent }
}
pub fn request(
&self,
method: &str,
url: &str,
data: Option<&[u8]>,
headers: Option<&HashMap<String, String>>,
retries: u32,
timeout_secs: Option<u64>,
) -> crate::Result<HttpResponse> {
let has_content_type = headers
.map(|h| h.contains_key("content-type"))
.unwrap_or(false);
let mut last_error: Option<BioLibError> = None;
crate::logging::debug(&format!("{method} {url}"));
for retry_count in 0..=retries {
if retry_count > 0 {
crate::logging::debug(&format!("Retrying {method} {url} (attempt {retry_count})"));
thread::sleep(Duration::from_secs(5 * retry_count as u64));
}
let mut builder = ureq::http::Request::builder().method(method).uri(url);
if let Some(hdrs) = headers {
for (key, value) in hdrs {
builder = builder.header(key.as_str(), value.as_str());
}
}
if data.is_some() && !has_content_type {
builder = builder
.header("content-type", "application/json")
.header("accept", "application/json");
}
let send_start = Instant::now();
let result = if let Some(body) = data {
let request = builder
.body(body.to_vec())
.map_err(|err| BioLibError::General(err.to_string()))?;
self.run_request(request, timeout_secs)
} else {
let request = builder
.body(())
.map_err(|err| BioLibError::General(err.to_string()))?;
self.run_request(request, timeout_secs)
};
match result {
Ok(mut response) => {
let status = response.status().as_u16();
crate::logging::debug(&format!(
"{method} {url} -> {status} ({}ms)",
send_start.elapsed().as_millis()
));
if status == 429 || status == 502 || status == 503 || status == 504 {
let _ = response.body_mut().read_to_string();
crate::logging::warning(&format!(
"Retryable HTTP {status} from {method} {url}"
));
last_error = Some(BioLibError::Http {
status,
message: format!("HTTP {method} request failed with status {status}"),
});
continue;
}
if status >= 400 {
let body_text = response.body_mut().read_to_string().unwrap_or_default();
crate::logging::debug(&format!(
"HTTP {status} error from {method} {url}: {body_text}"
));
return Err(BioLibError::Http {
status,
message: body_text,
});
}
return HttpResponse::from_ureq_response(response);
}
Err(err) => {
let elapsed = send_start.elapsed();
crate::logging::warning(&format!(
"Request failed after {}ms: {method} {url}: {err}",
elapsed.as_millis()
));
last_error = Some(BioLibError::Request(err.to_string()));
continue;
}
}
}
Err(last_error.unwrap_or_else(|| {
BioLibError::General(format!("Request failed after {retries} retries"))
}))
}
fn run_request<B: ureq::AsSendBody>(
&self,
request: ureq::http::Request<B>,
timeout_secs: Option<u64>,
) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
if let Some(secs) = timeout_secs {
let request = self
.agent
.configure_request(request)
.timeout_global(Some(Duration::from_secs(secs)))
.build();
self.agent.run(request)
} else {
self.agent.run(request)
}
}
pub fn get(
&self,
url: &str,
headers: Option<&HashMap<String, String>>,
) -> crate::Result<HttpResponse> {
self.request("GET", url, None, headers, 10, None)
}
pub fn get_with_range(&self, url: &str, start: u64, end: u64) -> crate::Result<HttpResponse> {
let mut headers = HashMap::new();
headers.insert("range".to_string(), format!("bytes={start}-{end}"));
self.request("GET", url, None, Some(&headers), 5, None)
}
}
pub struct HttpResponse {
pub status_code: u16,
pub body: Vec<u8>,
pub headers: HashMap<String, String>,
}
impl HttpResponse {
fn from_ureq_response(mut response: ureq::http::Response<ureq::Body>) -> crate::Result<Self> {
let status_code = response.status().as_u16();
let mut headers = HashMap::new();
for (key, value) in response.headers() {
if let Ok(v) = value.to_str() {
headers.insert(key.to_string(), v.to_string());
}
}
let body = response
.body_mut()
.with_config()
.limit(512 * 1024 * 1024)
.read_to_vec()
.map_err(|err| BioLibError::General(err.to_string()))?;
Ok(Self {
status_code,
body,
headers,
})
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).to_string()
}
pub fn json<T: serde::de::DeserializeOwned>(&self) -> crate::Result<T> {
serde_json::from_slice(&self.body).map_err(BioLibError::Json)
}
}