use std::{
fmt, str,
sync::{Arc, LazyLock, atomic::AtomicU64},
time::Duration,
};
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use dashmap::DashMap;
use percent_encoding::percent_decode_str;
use regex::{Regex, RegexBuilder};
use reqwest::{Client, RequestBuilder, StatusCode, Url};
use tokio::{sync::Semaphore, time::sleep};
use super::{
DataReaderTrait,
network_reader::{NetworkReader, SmallerRangeWontHelp},
retry::env_u64,
};
use crate::{Blob, ByteRange};
const DEFAULT_MAX_BODY_BYTES: u64 = 256 * 1024 * 1024;
enum BodyError {
Transport(reqwest::Error),
TooLarge { limit: u64 },
}
async fn read_body_limited(response: reqwest::Response, limit: u64) -> std::result::Result<Vec<u8>, BodyError> {
use futures::StreamExt;
let mut body = match response.content_length() {
Some(len) if len <= limit => Vec::with_capacity(usize::try_from(len).unwrap_or(0)),
_ => Vec::new(),
};
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(BodyError::Transport)?;
if body.len() as u64 + chunk.len() as u64 > limit {
return Err(BodyError::TooLarge { limit });
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
fn verify_content_range(response: &reqwest::Response, range: &ByteRange) -> Result<()> {
let content_range = response
.headers()
.get("content-range")
.ok_or_else(|| anyhow!("response is missing Content-Range header"))?
.to_str()?;
static RE_RANGE: LazyLock<Regex> = LazyLock::new(|| {
RegexBuilder::new(r"^bytes (\d+)-(\d+)/\d+$")
.case_insensitive(true)
.build()
.expect("valid regex literal")
});
let caps = RE_RANGE.captures(content_range).ok_or_else(|| {
anyhow!("unexpected Content-Range format: '{content_range}', expected 'bytes <start>-<end>/<total>'")
})?;
let content_range_start: u64 = caps[1].parse()?;
let content_range_end: u64 = caps[2].parse()?;
if content_range_start != range.offset {
bail!(
"Content-Range start mismatch: expected {}, got {content_range_start}",
range.offset
);
}
let expected_end = range.end()? - 1;
if content_range_end != expected_end {
bail!("Content-Range end mismatch: expected {expected_end}, got {content_range_end}");
}
Ok(())
}
const DEFAULT_MAX_IN_FLIGHT: usize = 8;
static HOST_SEMAPHORES: LazyLock<DashMap<String, Arc<Semaphore>>> = LazyLock::new(DashMap::new);
fn host_semaphore(url: &Url) -> Arc<Semaphore> {
let key = url.host_str().unwrap_or_else(|| url.as_str()).to_string();
HOST_SEMAPHORES
.entry(key)
.or_insert_with(|| Arc::new(Semaphore::new(DEFAULT_MAX_IN_FLIGHT)))
.clone()
}
pub struct DataReaderHttp {
client: Client,
name: String,
url: Url,
username: Option<String>,
password: Option<String>,
max_request_bytes: AtomicU64,
in_flight: Arc<Semaphore>,
}
impl fmt::Debug for DataReaderHttp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DataReaderHttp")
.field("url", &self.url.as_str())
.field("has_credentials", &self.username.is_some())
.finish()
}
}
impl TryFrom<&Url> for DataReaderHttp {
type Error = anyhow::Error;
fn try_from(url: &Url) -> Result<DataReaderHttp> {
let mut url = url.clone();
let username = if url.username().is_empty() {
None
} else {
Some(percent_decode_str(url.username()).decode_utf8()?.into_owned())
};
let password: Option<String> = if let Some(p) = url.password() {
Some(if let Ok(v) = percent_decode_str(p).decode_utf8() {
v.into_owned()
} else {
bail!("failed to decode password");
})
} else {
None
};
url.set_username("").map_err(|_| anyhow!("failed to set username"))?;
url.set_password(None).map_err(|_| anyhow!("failed to set password"))?;
match url.scheme() {
"http" | "https" => (),
other => bail!("unsupported URL scheme '{other}' in '{url}', expected 'http' or 'https'"),
}
let client = Client::builder()
.user_agent(crate::io::user_agent())
.connect_timeout(Duration::from_secs(30))
.tcp_keepalive(Duration::from_secs(60))
.use_rustls_tls()
.build()?;
let in_flight = host_semaphore(&url);
Ok(DataReaderHttp {
client,
name: url.to_string(),
url,
username,
password,
max_request_bytes: AtomicU64::new(u64::MAX),
in_flight,
})
}
}
impl DataReaderHttp {
fn apply_auth(&self, builder: RequestBuilder) -> RequestBuilder {
if let Some(username) = &self.username {
builder.basic_auth(username, self.password.as_deref())
} else {
builder
}
}
#[must_use]
pub fn with_max_in_flight(mut self, n: usize) -> Self {
self.in_flight = Arc::new(Semaphore::new(n.max(2)));
self
}
}
fn is_retryable_error(err: &reqwest::Error) -> bool {
err.is_connect() || err.is_timeout() || err.is_body()
}
fn unexpected_status(range: &ByteRange, len: u64, url: &Url, status: StatusCode, total_attempts: u32) -> anyhow::Error {
if status.is_server_error() {
return anyhow!(
"could not read {range} ({len} bytes) from '{url}': server returned {status} — gave up after {total_attempts} attempts"
);
}
let error = anyhow!("could not read {range} ({len} bytes) from '{url}': expected HTTP 206, got {status}");
if status == StatusCode::PAYLOAD_TOO_LARGE {
error
} else {
error.context(SmallerRangeWontHelp)
}
}
fn describe_error(err: &reqwest::Error) -> String {
use std::error::Error;
let mut parts: Vec<String> = vec![err.to_string()];
let mut src: Option<&dyn Error> = err.source();
while let Some(s) = src {
parts.push(s.to_string());
src = s.source();
}
parts.join(" -> ")
}
impl DataReaderHttp {
async fn try_read_range_impl(&self, range: &ByteRange) -> Result<Blob> {
let request_range: String = format!("bytes={}-{}", range.offset, range.end()? - 1);
let policy = super::retry::policy();
let max_retries = policy.max_retries;
let total_attempts = max_retries + 1;
let url = &self.url;
let len = range.length;
for attempt in 0..=max_retries {
let attempt_label = format!("attempt {}/{total_attempts}", attempt + 1);
if attempt > 0 {
let backoff = policy.backoff(attempt - 1);
log::warn!("HTTP read {range} from '{url}': retrying ({attempt_label}, waiting {backoff:?})");
sleep(backoff).await;
}
log::trace!("http: {range} {attempt_label}: sending request");
let _permit = self
.in_flight
.clone()
.acquire_owned()
.await
.expect("in-flight semaphore is never closed");
let response = match self
.apply_auth(self.client.get(self.url.clone()))
.header("range", &request_range)
.send()
.await
{
Ok(r) => r,
Err(e) if is_retryable_error(&e) && attempt < max_retries => {
log::warn!(
"HTTP read {range} from '{url}': {} ({attempt_label}), will retry",
describe_error(&e)
);
continue;
}
Err(e) => {
bail!(
"could not read {range} ({len} bytes) from '{url}': {} — gave up after {total_attempts} attempts",
describe_error(&e)
)
}
};
let status = response.status();
if status.is_server_error() && attempt < max_retries {
log::warn!("HTTP read {range} from '{url}': server returned {status} ({attempt_label}), will retry");
continue;
}
if status != StatusCode::PARTIAL_CONTENT {
return Err(unexpected_status(range, len, url, status, total_attempts));
}
verify_content_range(&response, range)?;
let bytes = match read_body_limited(response, range.length).await {
Ok(b) => b,
Err(BodyError::Transport(e)) if is_retryable_error(&e) && attempt < max_retries => {
log::warn!(
"HTTP read {range} from '{url}': error reading body: {} ({attempt_label}), will retry",
describe_error(&e)
);
continue;
}
Err(BodyError::Transport(e)) => bail!(
"could not read {range} ({len} bytes) from '{url}': error reading body: {} — gave up after {total_attempts} attempts",
describe_error(&e)
),
Err(BodyError::TooLarge { limit }) => {
return Err(
anyhow!(
"could not read {range} from '{url}': the response body is longer than the {limit} bytes requested"
)
.context(SmallerRangeWontHelp),
);
}
};
if bytes.len() as u64 != range.length {
return Err(
anyhow!(
"could not read {range} from '{url}': expected {} bytes, the response body carried {}",
range.length,
bytes.len()
)
.context(SmallerRangeWontHelp),
);
}
log::trace!("http: {range} {attempt_label}: read {} bytes ok", bytes.len());
return Ok(Blob::from(bytes));
}
bail!("could not read {range} ({len} bytes) from '{url}' — gave up after {total_attempts} attempts")
}
}
#[async_trait]
impl NetworkReader for DataReaderHttp {
async fn try_read_range(&self, range: &ByteRange) -> Result<Blob> {
self.try_read_range_impl(range).await
}
fn max_request_bytes(&self) -> &AtomicU64 {
&self.max_request_bytes
}
}
#[async_trait]
impl DataReaderTrait for DataReaderHttp {
async fn read_range(&self, range: &ByteRange) -> Result<Blob> {
self.network_read_range(range).await
}
async fn read_all(&self) -> Result<Blob> {
let policy = super::retry::policy();
let max_retries = policy.max_retries;
let total_attempts = max_retries + 1;
let url = &self.url;
for attempt in 0..=max_retries {
let attempt_label = format!("attempt {}/{total_attempts}", attempt + 1);
if attempt > 0 {
let backoff = policy.backoff(attempt - 1);
log::warn!("HTTP read from '{url}': retrying ({attempt_label}, waiting {backoff:?})");
sleep(backoff).await;
}
let _permit = self
.in_flight
.clone()
.acquire_owned()
.await
.expect("in-flight semaphore is never closed");
let response = match self.apply_auth(self.client.get(self.url.clone())).send().await {
Ok(r) => r,
Err(e) if is_retryable_error(&e) && attempt < max_retries => {
log::warn!(
"HTTP read from '{url}': {} ({attempt_label}), will retry",
describe_error(&e)
);
continue;
}
Err(e) => bail!(
"could not read from '{url}': {} — gave up after {total_attempts} attempts",
describe_error(&e)
),
};
let status = response.status();
if status.is_server_error() && attempt < max_retries {
log::warn!("HTTP read from '{url}': server returned {status} ({attempt_label}), will retry");
continue;
}
if !status.is_success() {
if status.is_server_error() {
bail!("could not read from '{url}': server returned {status} — gave up after {total_attempts} attempts");
}
bail!("could not read from '{url}': server returned {status}");
}
let limit = env_u64("VERSATILES_MAX_HTTP_BODY_BYTES", DEFAULT_MAX_BODY_BYTES);
let bytes = match read_body_limited(response, limit).await {
Ok(b) => b,
Err(BodyError::Transport(e)) if is_retryable_error(&e) && attempt < max_retries => {
log::warn!(
"HTTP read from '{url}': error reading body: {} ({attempt_label}), will retry",
describe_error(&e)
);
continue;
}
Err(BodyError::Transport(e)) => {
bail!(
"could not read from '{url}': error reading body: {} — gave up after {total_attempts} attempts",
describe_error(&e)
)
}
Err(BodyError::TooLarge { limit }) => bail!(
"could not read from '{url}': the response body exceeds {limit} bytes \
(raise VERSATILES_MAX_HTTP_BODY_BYTES if that is expected)"
),
};
return Ok(Blob::from(bytes));
}
bail!("could not read from '{url}' — gave up after {total_attempts} attempts")
}
fn name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new() {
let valid_url = Url::parse("https://www.example.com").unwrap();
let invalid_url = Url::parse("ftp://www.example.com").unwrap();
let data_reader_http = DataReaderHttp::try_from(&valid_url);
assert!(data_reader_http.is_ok());
let data_reader_http = DataReaderHttp::try_from(&invalid_url);
assert!(data_reader_http.is_err());
}
async fn read_range_helper(url: &str, offset: u64, length: u64, expected: &str) -> Result<()> {
let url = Url::parse(url).unwrap();
let data_reader_http = DataReaderHttp::try_from(&url)?;
let range = ByteRange { offset, length };
let blob = data_reader_http.read_range(&range).await?;
let result_text = str::from_utf8(blob.as_slice())?;
assert_eq!(result_text, expected);
Ok(())
}
#[tokio::test]
async fn read_range_git() {
read_range_helper(
"https://raw.githubusercontent.com/versatiles-org/versatiles-rs/refs/heads/main/testdata/berlin.mbtiles",
7,
8,
"format 3",
)
.await
.unwrap();
}
#[tokio::test]
async fn read_range_google() {
read_range_helper("https://google.com/", 100, 110, "plingplong")
.await
.unwrap_err();
}
#[test]
fn get_name() -> Result<()> {
let url = "https://www.example.com/";
let data_reader_http = DataReaderHttp::try_from(&Url::parse(url).unwrap())?;
assert_eq!(data_reader_http.name(), url);
Ok(())
}
#[test]
fn from_url_with_credentials() -> Result<()> {
let url = Url::parse("https://user:p%40ss@example.com/data.bin").unwrap();
let reader = DataReaderHttp::try_from(&url)?;
assert_eq!(reader.username.as_deref(), Some("user"));
assert_eq!(reader.password.as_deref(), Some("p@ss"));
assert_eq!(reader.name(), "https://example.com/data.bin");
assert_eq!(reader.url.username(), "");
assert_eq!(reader.url.password(), None);
Ok(())
}
#[test]
fn from_url_without_credentials() -> Result<()> {
let url = Url::parse("https://example.com/data.bin").unwrap();
let reader = DataReaderHttp::try_from(&url)?;
assert_eq!(reader.username, None);
assert_eq!(reader.password, None);
assert_eq!(reader.name(), "https://example.com/data.bin");
Ok(())
}
#[test]
fn debug_impl_hides_credentials() -> Result<()> {
let with_creds = DataReaderHttp::try_from(&Url::parse("https://user:pass@example.com/").unwrap())?;
let debug = format!("{with_creds:?}");
assert!(debug.contains("has_credentials: true"));
assert!(!debug.contains("pass"));
let no_creds = DataReaderHttp::try_from(&Url::parse("https://example.com/").unwrap())?;
let debug = format!("{no_creds:?}");
assert!(debug.contains("has_credentials: false"));
Ok(())
}
#[test]
fn from_url_rejects_unsupported_scheme() {
let url = Url::parse("ftp://example.com/").unwrap();
let err = DataReaderHttp::try_from(&url).unwrap_err();
assert!(err.to_string().contains("unsupported URL scheme"));
}
#[test]
fn default_in_flight_cap() -> Result<()> {
let reader = DataReaderHttp::try_from(&Url::parse("https://default-cap-test.invalid/").unwrap())?;
assert_eq!(reader.in_flight.available_permits(), DEFAULT_MAX_IN_FLIGHT);
Ok(())
}
#[test]
fn with_max_in_flight_overrides_cap() -> Result<()> {
let reader =
DataReaderHttp::try_from(&Url::parse("https://override-cap-test.invalid/").unwrap())?.with_max_in_flight(8);
assert_eq!(reader.in_flight.available_permits(), 8);
Ok(())
}
#[test]
fn with_max_in_flight_clamps_below_two() -> Result<()> {
let reader =
DataReaderHttp::try_from(&Url::parse("https://clamp-low-test.invalid/").unwrap())?.with_max_in_flight(0);
assert_eq!(reader.in_flight.available_permits(), 2);
let reader =
DataReaderHttp::try_from(&Url::parse("https://clamp-one-test.invalid/").unwrap())?.with_max_in_flight(1);
assert_eq!(reader.in_flight.available_permits(), 2);
Ok(())
}
#[test]
fn semaphore_shared_across_readers_with_same_host() -> Result<()> {
let r1 = DataReaderHttp::try_from(&Url::parse("https://shared-host-test.invalid/a.bin").unwrap())?;
let r2 = DataReaderHttp::try_from(&Url::parse("https://shared-host-test.invalid/b.bin").unwrap())?;
assert!(Arc::ptr_eq(&r1.in_flight, &r2.in_flight));
Ok(())
}
#[test]
fn semaphore_distinct_across_hosts() -> Result<()> {
let r1 = DataReaderHttp::try_from(&Url::parse("https://host-a-test.invalid/").unwrap())?;
let r2 = DataReaderHttp::try_from(&Url::parse("https://host-b-test.invalid/").unwrap())?;
assert!(!Arc::ptr_eq(&r1.in_flight, &r2.in_flight));
Ok(())
}
}
#[cfg(test)]
mod body_limit_tests {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::*;
async fn serve_once(response: Vec<u8>) -> Url {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((mut socket, _)) = listener.accept().await {
let mut discard = [0u8; 2048];
let _ = socket.read(&mut discard).await;
let _ = socket.write_all(&response).await;
let _ = socket.flush().await;
}
});
Url::parse(&format!("http://{addr}/x")).unwrap()
}
fn partial_response(content_range: &str, body: &[u8]) -> Vec<u8> {
let mut out = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Range: {content_range}\r\nContent-Length: {}\r\n\r\n",
body.len()
)
.into_bytes();
out.extend_from_slice(body);
out
}
#[tokio::test]
async fn a_body_longer_than_the_requested_range_is_refused() {
let body = vec![b'x'; 5000];
let url = serve_once(partial_response("bytes 0-9/100", &body)).await;
let reader = DataReaderHttp::try_from(&url).unwrap();
let error = reader
.read_range(&ByteRange::new(0, 10))
.await
.expect_err("an over-long body must be refused");
let message = format!("{error:#}");
assert!(message.contains("longer than"), "unhelpful message: {message}");
}
#[tokio::test]
async fn a_body_shorter_than_the_requested_range_is_refused() {
let url = serve_once(partial_response("bytes 0-9/100", b"abc")).await;
let reader = DataReaderHttp::try_from(&url).unwrap();
let error = reader
.read_range(&ByteRange::new(0, 10))
.await
.expect_err("a short body must be refused");
let message = format!("{error:#}");
assert!(
message.contains("expected 10 bytes") && message.contains("carried 3"),
"unhelpful message: {message}"
);
}
#[tokio::test]
async fn a_body_matching_the_requested_range_is_accepted() {
let url = serve_once(partial_response("bytes 0-9/100", b"0123456789")).await;
let reader = DataReaderHttp::try_from(&url).unwrap();
let blob = reader.read_range(&ByteRange::new(0, 10)).await.unwrap();
assert_eq!(blob.as_slice(), b"0123456789");
}
}