use std::{
io::{self, Read},
time::{Duration, Instant},
};
use color_eyre::eyre::{Report, eyre};
use reqwest::{StatusCode, Url, redirect};
use tracing::{debug, warn};
use super::LOG_TARGET;
const HTTP_TIMEOUT: Duration = Duration::from_mins(2);
const HTTP_ATTEMPTS: u32 = 3;
const HTTP_BACKOFF: Duration = Duration::from_millis(200);
#[must_use]
pub fn is_permitted_url(url: &str) -> bool {
Url::parse(url).is_ok_and(|parsed| is_permitted(&parsed))
}
fn is_permitted(url: &Url) -> bool {
match url.scheme() {
"https" => true,
"http" => url.host_str().is_some_and(is_loopback_host),
_ => false,
}
}
fn is_loopback_host(host: &str) -> bool {
host == "localhost"
|| host
.trim_matches(['[', ']'])
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[must_use]
pub fn redact_url(url: &str) -> String {
let Ok(parsed) = Url::parse(url) else {
return "<unparsable url>".to_owned();
};
let scheme = parsed.scheme();
let host = parsed.host_str().unwrap_or("<no host>");
let path = parsed.path();
parsed.port().map_or_else(
|| format!("{scheme}://{host}{path}"),
|port| format!("{scheme}://{host}:{port}{path}"),
)
}
fn build_client() -> Result<reqwest::blocking::Client, Report> {
let policy = redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 10 {
attempt.error("too many redirects")
} else if is_permitted(attempt.url()) {
attempt.follow()
} else {
attempt.error("redirect to a URL that is not permitted")
}
});
reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.redirect(policy)
.build()
.map_err(|err| eyre!("cannot build HTTP client: {err}"))
}
pub(super) fn http_get(url: &str, cap: u64, writer: &mut dyn io::Write) -> Result<u64, Report> {
let Some(parsed) = Url::parse(url).ok().filter(is_permitted) else {
return Err(eyre!(
"{} is not an https:// URL (loopback http is the only exception)",
redact_url(url)
));
};
let client = build_client()?;
let started = Instant::now();
let (response, attempts) = fetch_with_retry(&client, &parsed)?;
let received = io::copy(&mut response.take(cap + 1), writer)
.map_err(|err| eyre!("body read failed: {err}"))?;
debug!(
target: LOG_TARGET,
url = %redact_url(url),
bytes = received,
attempts,
elapsed_ms = millis(started.elapsed()),
"http get complete"
);
Ok(received)
}
fn fetch_with_retry(
client: &reqwest::blocking::Client,
url: &Url,
) -> Result<(reqwest::blocking::Response, u32), Report> {
let mut attempt = 1;
loop {
match send(client, url) {
Ok(response) => return Ok((response, attempt)),
Err(Failure::Permanent(err)) => return Err(err),
Err(Failure::Transient(err)) if attempt >= HTTP_ATTEMPTS => {
return Err(err.wrap_err(format!("giving up after {attempt} attempts")));
}
Err(Failure::Transient(err)) => {
let delay = HTTP_BACKOFF * 2_u32.pow(attempt - 1);
warn!(
target: LOG_TARGET,
url = %redact_url(url.as_str()),
attempt,
error = %err,
delay_ms = millis(delay),
"transient http failure; retrying"
);
std::thread::sleep(delay);
attempt += 1;
}
}
}
}
pub(super) fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
enum Failure {
Transient(Report),
Permanent(Report),
}
fn transport_error(err: reqwest::Error) -> Report { eyre!("{}", err.without_url()) }
fn send(
client: &reqwest::blocking::Client,
url: &Url,
) -> Result<reqwest::blocking::Response, Failure> {
let response = client.get(url.clone()).send().map_err(|err| {
if err.is_redirect() {
Failure::Permanent(transport_error(err))
} else {
Failure::Transient(transport_error(err))
}
})?;
let status = response.status();
if status.is_success() {
Ok(response)
} else if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
Err(Failure::Transient(eyre!("server returned {status}")))
} else {
Err(Failure::Permanent(eyre!("server returned {status}")))
}
}