blob: d2ed339631b9a8f523921500a5d8e79ab73a9715 [file] [edit]
//! Async wrapper around cURL for making managing HTTP requests.
//!
//! Requests are executed in parallel using cURL [`Multi`] on
//! a worker thread that is owned by the Client.
use crate::util::data_structures::HashMap;
use std::io::Cursor;
use std::io::Read;
use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::sync::mpsc::Receiver;
use std::sync::mpsc::Sender;
use std::thread::JoinHandle;
use std::time::Duration;
use std::time::Instant;
use curl::easy::Easy2;
use curl::easy::Handler;
use curl::easy::InfoType;
use curl::easy::WriteError;
use curl::multi::Easy2Handle;
use curl::multi::Multi;
use futures::channel::oneshot;
use portable_atomic::AtomicI64;
use portable_atomic::AtomicU64;
use tracing::{debug, error, trace, warn};
use crate::util::network::http::HandleConfiguration;
use crate::util::network::http::HttpTimeout;
type Response = http::Response<Vec<u8>>;
type Request = http::Request<Vec<u8>>;
type HttpResult<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(transparent)]
Multi(#[from] curl::MultiError),
#[error(transparent)]
Easy(#[from] curl::Error),
#[error(
"transfer too slow: failed to transfer more than {low_speed_limit} bytes in {}s (transferred {transferred} bytes)",
timeout_dur.as_secs()
)]
TooSlow {
low_speed_limit: u32,
timeout_dur: Duration,
transferred: u64,
},
#[error("failed to convert header value of `{name}` to string: {bytes:?}")]
BadHeader { name: String, bytes: Vec<u8> },
}
struct Message {
easy: Easy2<Collector>,
sender: oneshot::Sender<HttpResult<Response>>,
}
#[derive(Default)]
struct Stats {
dl_remaining: AtomicI64,
dl_transferred: AtomicU64,
}
/// HTTP Client. Creating a new client spawns a cURL `Multi` and
/// thread that is used for all HTTP requests by this client.
pub struct Client {
channel: Option<Sender<Message>>,
thread_handle: Option<JoinHandle<()>>,
handle_config: HandleConfiguration,
stats: Arc<Stats>,
}
impl Client {
/// Spawns a new worker thread where HTTP request execute.
pub fn new(handle_config: HandleConfiguration) -> Client {
let (tx, rx) = mpsc::channel();
let stats = Arc::new(Stats::default());
let timeout = handle_config.timeout.clone();
let worker_stats = stats.clone();
let handle = std::thread::spawn(move || {
WorkerServer::run(rx, handle_config.multiplexing, timeout, worker_stats)
});
Client {
channel: Some(tx),
thread_handle: Some(handle),
handle_config,
stats,
}
}
/// Perform a blocking HTTP request using this client.
/// Does not start an async executor.
pub fn request_blocking(&self, request: Request) -> HttpResult<Response> {
let mut handle = self.request_helper(request)?;
// Configure the handle timeout since we're blocking here and not using the
// client-level timeout.
self.handle_config.timeout.configure2(&mut handle)?;
handle.perform()?;
Ok(WorkerServer::process_response(handle))
}
/// Perform an HTTP request using this client.
pub async fn request(&self, request: Request) -> HttpResult<Response> {
let handle = self.request_helper(request)?;
let (sender, receiver) = oneshot::channel();
let req = Message {
easy: handle,
sender,
};
self.channel.as_ref().unwrap().send(req).unwrap();
receiver.await.unwrap()
}
fn request_helper(&self, request: Request) -> HttpResult<Easy2<Collector>> {
let url = request.uri().to_string();
debug!(target: "network::fetch", url);
let mut collector = Collector::new(self.stats.clone());
let (parts, body) = request.into_parts();
let body_len = body.len();
collector.request_body = Cursor::new(body);
collector.debug = self.handle_config.verbose;
let mut handle = curl::easy::Easy2::new(collector);
self.handle_config.configure2(&mut handle)?;
handle.url(&url)?;
handle.follow_location(true)?;
handle.progress(true)?;
match parts.method {
http::Method::HEAD => handle.nobody(true)?,
http::Method::GET => handle.get(true)?,
http::Method::POST => {
handle.post_field_size(body_len as u64)?;
handle.post(true)?;
}
http::Method::PUT => {
handle.in_filesize(body_len as u64)?;
handle.put(true)?;
}
method => {
if body_len > 0 {
handle.upload(true)?;
handle.in_filesize(body_len as u64)?;
}
handle.custom_request(method.as_str())?;
}
}
let mut headers = curl::easy::List::new();
for (name, value) in parts.headers {
if let Some(name) = name {
let value: &str = value.to_str().map_err(|_| Error::BadHeader {
name: name.to_string(),
bytes: value.as_bytes().to_owned(),
})?;
headers.append(&format!("{}: {}", name, value))?;
}
}
handle.http_headers(headers)?;
Ok(handle)
}
/// Returns the number pending bytes across all active transfers.
pub fn bytes_pending(&self) -> u64 {
self.stats
.dl_remaining
.load(Ordering::Acquire)
.try_into()
.unwrap()
}
}
impl Drop for Client {
fn drop(&mut self) {
// Close the channel
drop(self.channel.take().unwrap());
// Join the thread
let _ = self.thread_handle.take().unwrap().join();
}
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("http_async::Client").finish()
}
}
/// Manages the cURL `Multi`. Processes incoming work sent over the
/// channel, and returns responses.
struct WorkerServer {
/// Channel to receive new work
incoming_work: Receiver<Message>,
/// curl multi interface
multi: Multi,
/// Map of token to curl handle and response channel
handles: HashMap<
usize,
(
Easy2Handle<Collector>,
oneshot::Sender<HttpResult<Response>>,
),
>,
/// Next token to use
token: usize,
/// Global timeout configuration
timeout: HttpTimeout,
/// Global transfer statistics
stats: Arc<Stats>,
/// Instant when the current low speed window started
low_speed_window_start: Instant,
/// Amount of total bytes transferred when the current low speed window started
low_speed_window_initial: u64,
}
impl WorkerServer {
fn run(
incoming_work: Receiver<Message>,
multiplex: bool,
timeout: HttpTimeout,
stats: Arc<Stats>,
) {
let mut multi = Multi::new();
// let's not flood the server with connections
if let Err(e) = multi.set_max_host_connections(2) {
error!("failed to set max host connections in curl: {e}");
}
if let Err(e) = multi.pipelining(false, multiplex) {
error!("failed to enable multiplexing/pipelining in curl: {e}");
}
let mut worker = Self {
incoming_work,
multi,
handles: HashMap::default(),
token: 0,
timeout,
stats,
low_speed_window_start: Instant::now(),
low_speed_window_initial: 0,
};
worker.worker_loop();
}
fn fail_and_drain(&mut self, e: &Error) {
warn!(
target: "network",
"failing all outstanding HTTP requests: {e}"
);
for (_token, (_handle, sender)) in self.handles.drain() {
let _ = sender.send(Err(e.clone()));
}
}
fn process_response(mut easy: Easy2<Collector>) -> Response {
let mut response =
std::mem::replace(&mut easy.get_mut().response, Response::new(Vec::new()));
if let Ok(status) = easy.response_code()
&& status != 0
&& let Ok(status) = http::StatusCode::from_u16(status as u16)
{
*response.status_mut() = status;
}
// Would be nice to set HTTP version via `response.version_mut()`, but `curl` doesn't have it exposed.
let extensions = Extensions {
client_ip: easy.primary_ip().ok().flatten().map(str::to_string),
effective_url: easy.effective_url().ok().flatten().map(str::to_string),
};
response.extensions_mut().insert(extensions);
response
}
/// Marks the start of a new timeout window.
fn reset_low_speed_timeout(&mut self) {
self.low_speed_window_start = Instant::now();
self.low_speed_window_initial = self.stats.dl_transferred.load(Ordering::Acquire);
}
/// Return an error if we're at the end of a timeout window, we haven't
/// made enough progress.
fn check_low_speed_timeout(&mut self) -> Option<Error> {
// Make sure we've waited for the timeout duration
if Instant::now().duration_since(self.low_speed_window_start) < self.timeout.dur {
return None;
}
// Calculate how much we've transferred since the last check.
let current = self.stats.dl_transferred.load(Ordering::Acquire);
let transferred = current.saturating_sub(self.low_speed_window_initial);
self.reset_low_speed_timeout();
if transferred < self.timeout.low_speed_limit.into() {
Some(Error::TooSlow {
low_speed_limit: self.timeout.low_speed_limit,
timeout_dur: self.timeout.dur,
transferred,
})
} else {
None
}
}
fn worker_loop(&mut self) {
const INITIAL_DELAY: Duration = Duration::from_millis(1);
let mut wait_backoff = INITIAL_DELAY;
loop {
// Start any pending work.
while let Ok(msg) = self.incoming_work.try_recv() {
self.enqueue_request(msg);
wait_backoff = INITIAL_DELAY;
}
match self.multi.perform() {
Err(e) if e.is_call_perform() => {
// cURL states if you receive `is_call_perform`, this means that you should call `perform` again.
}
Err(e) => {
self.fail_and_drain(&Error::Multi(e));
}
Ok(running) => {
self.multi.messages(|msg| {
let t = msg.token().expect("all handles have tokens");
trace!(token = t, "finish");
let Some((handle, sender)) = self.handles.remove(&t) else {
error!("missing entry {t} in handle table");
return;
};
let result = msg.result_for2(&handle).expect("handle must have a result");
let easy = self.multi.remove2(handle).expect("handle must be in multi");
let response = Self::process_response(easy);
let _ = sender.send(result.map(|()| response).map_err(Into::into));
});
if running > 0 {
// Check for low speed timeout.
if let Some(timeout_error) = self.check_low_speed_timeout() {
self.fail_and_drain(&timeout_error);
continue;
}
let max_timeout = Duration::from_millis(1000);
let mut timeout = self
.multi
.get_timeout()
.ok()
.flatten()
.unwrap_or(max_timeout)
.min(max_timeout);
if timeout.is_zero() {
// curl said not to wait.
continue;
}
// Ideally we would use `Multi::poll` + a `MultiWaker` instead of `Multi::wait`
// to wake the thread when new work is queued. But it requires curl 7.68+,
// which is not available everywhere we support.
//
// Instead, we use an exponential backoff approach so that as long as requests
// are being queued, we poll quickly to allow the requests to be added sooner.
// Without this, we end up sitting in `Multi::wait` too long while new work is
// added to the channel.
//
// `get_timeout` says we should wait *at most* the timeout amount, so reducing
// the wait time is fine.
if wait_backoff < timeout {
wait_backoff *= 2;
timeout = wait_backoff
}
trace!(
pending = self.handles.len(),
timeout = timeout.as_millis(),
"curl wait"
);
if let Err(e) = self.multi.wait(&mut [], timeout) {
self.fail_and_drain(&Error::Multi(e));
}
} else {
// Block, waiting for more work
trace!("all work completed");
match self.incoming_work.recv() {
Ok(msg) => {
trace!("resuming work");
self.reset_low_speed_timeout();
self.enqueue_request(msg);
wait_backoff = INITIAL_DELAY;
}
Err(_) => {
// The sending channel is closed. Shut down the worker.
break;
}
}
}
}
}
}
}
/// Adds the request to the `Multi`, or send an error back through the channel.
fn enqueue_request(&mut self, message: Message) {
match self.multi.add2(message.easy) {
Ok(mut handle) => {
self.token = self.token.wrapping_add(1);
handle.set_token(self.token).ok();
self.handles.insert(self.token, (handle, message.sender));
}
Err(e) => {
let _ = message.sender.send(Err(e.into()));
}
}
}
}
/// Interface that cURL (`Easy2`) uses to make progress.
struct Collector {
/// The response being built
response: Response,
/// The body to transmit
request_body: Cursor<Vec<u8>>,
/// Whether we're in debug mode
debug: bool,
/// Global transfer statistics.
global_stats: Arc<Stats>,
/// How much has this particular transfer added to global `dl_remaining` stats.
dl_remaining_delta: i64,
}
impl Collector {
fn new(stats: Arc<Stats>) -> Self {
Collector {
response: Response::new(Vec::new()),
request_body: Cursor::new(Vec::new()),
debug: false,
global_stats: stats,
dl_remaining_delta: 0,
}
}
}
impl Handler for Collector {
fn write(&mut self, data: &[u8]) -> Result<usize, WriteError> {
self.response.body_mut().extend_from_slice(data);
self.global_stats
.dl_transferred
.fetch_add(data.len() as u64, Ordering::Release);
Ok(data.len())
}
fn header(&mut self, data: &[u8]) -> bool {
if let Some((name, value)) = handle_http_header(data)
&& let Ok(name) = http::HeaderName::from_str(name)
&& let Ok(value) = http::HeaderValue::from_str(value)
{
self.response.headers_mut().append(name, value);
}
true
}
fn read(&mut self, data: &mut [u8]) -> Result<usize, curl::easy::ReadError> {
Ok(self.request_body.read(data).unwrap())
}
fn debug(&mut self, kind: InfoType, data: &[u8]) {
if self.debug {
super::http::debug(kind, data);
}
}
fn progress(&mut self, dltotal: f64, dlnow: f64, _ultotal: f64, _ulnow: f64) -> bool {
if dlnow > dltotal {
return true;
}
let dl_total = dltotal as i64;
let dl_current = dlnow as i64;
let remaining = dl_total - dl_current;
self.global_stats
.dl_remaining
.fetch_add(remaining - self.dl_remaining_delta, Ordering::Release);
self.dl_remaining_delta = remaining;
true
}
}
impl Drop for Collector {
fn drop(&mut self) {
// Zero out this transfer's contribution to the global dl_remaining.
self.global_stats
.dl_remaining
.fetch_add(-self.dl_remaining_delta, Ordering::Release);
}
}
/// Additional fields on an [`http::Response`].
#[derive(Clone)]
struct Extensions {
client_ip: Option<String>,
effective_url: Option<String>,
}
pub trait ResponsePartsExtensions {
fn client_ip(&self) -> Option<&str>;
fn effective_url(&self) -> Option<&str>;
}
impl ResponsePartsExtensions for http::response::Parts {
fn client_ip(&self) -> Option<&str> {
self.extensions
.get::<Extensions>()
.and_then(|extensions| extensions.client_ip.as_deref())
}
fn effective_url(&self) -> Option<&str> {
self.extensions
.get::<Extensions>()
.and_then(|extensions| extensions.effective_url.as_deref())
}
}
impl ResponsePartsExtensions for Response {
fn client_ip(&self) -> Option<&str> {
self.extensions()
.get::<Extensions>()
.and_then(|extensions| extensions.client_ip.as_deref())
}
fn effective_url(&self) -> Option<&str> {
self.extensions()
.get::<Extensions>()
.and_then(|extensions| extensions.effective_url.as_deref())
}
}
/// Splits HTTP `HEADER: VALUE` to a tuple.
fn handle_http_header(buf: &[u8]) -> Option<(&str, &str)> {
if buf.is_empty() {
return None;
}
let buf = std::str::from_utf8(buf).ok()?.trim_end();
// Don't let server sneak extra lines anywhere.
if buf.contains('\n') {
return None;
}
let (tag, value) = buf.split_once(':')?;
let value = value.trim();
Some((tag, value))
}