use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::{Request, Response};
use crate::error::Error;
use crate::proxy::ProxyList;
use crate::rate_limit::RateLimiter;
#[cfg(feature = "tracing")]
use crate::retry::error_kind;
use crate::retry::{
backoff_delay, is_idempotent, is_proxy_failure_status, is_retryable_status, is_transport_error,
retry_after, should_retry_error,
};
use crate::trace_log;
const DEFAULT_RETRIES: u32 = 3;
const DEFAULT_BACKOFF_BASE: Duration = Duration::from_millis(200);
const DEFAULT_BACKOFF_MAX: Duration = Duration::from_secs(30);
const DEFAULT_MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
const DEFAULT_PROXY_COOLDOWN: Duration = Duration::from_secs(60);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DRAIN_BUDGET: usize = 64 * 1024;
type ConfigureFn = dyn Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send + Sync;
#[derive(Clone, Debug)]
pub struct RotatingClient {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
direct_client: reqwest::Client,
proxy_clients: Vec<reqwest::Client>,
proxies: ProxyList,
rate_limiter: RateLimiter,
retries: u32,
backoff_base: Duration,
backoff_max: Duration,
max_retry_after: Duration,
proxy_cooldown: Duration,
}
impl RotatingClient {
#[must_use]
pub fn builder() -> RotatingClientBuilder {
RotatingClientBuilder::default()
}
pub async fn get(&self, url: impl reqwest::IntoUrl) -> Result<Response, Error> {
let request = self.inner.direct_client.get(url).build()?;
self.send_with_retry(request).await
}
pub fn request(&self, method: reqwest::Method, url: impl reqwest::IntoUrl) -> RequestBuilder {
RequestBuilder {
client: self.clone(),
inner: self.inner.direct_client.request(method, url),
}
}
pub async fn send(&self, request_builder: reqwest::RequestBuilder) -> Result<Response, Error> {
let request = request_builder.build()?;
self.send_with_retry(request).await
}
pub async fn execute(&self, request: Request) -> Result<Response, Error> {
self.send_with_retry(request).await
}
#[must_use]
pub fn proxies(&self) -> &ProxyList {
&self.inner.proxies
}
async fn send_with_retry(&self, request: Request) -> Result<Response, Error> {
let inner = &*self.inner;
let idempotent = is_idempotent(request.method());
let mut pending = Some(request);
let mut attempt: u32 = 0;
loop {
let mut is_last_attempt = attempt >= inner.retries;
let current = if is_last_attempt {
pending
.take()
.expect("request is kept until the last attempt")
} else {
match pending
.as_ref()
.expect("request is kept until the last attempt")
.try_clone()
{
Some(clone) => clone,
None => {
is_last_attempt = true;
pending
.take()
.expect("request is kept until the last attempt")
}
}
};
inner
.rate_limiter
.wait(current.url().host_str().unwrap_or(""))
.await;
let proxy_idx = inner.proxies.pick_index();
let client = match proxy_idx {
Some(idx) => &inner.proxy_clients[idx],
None => &inner.direct_client,
};
trace_log!(
"attempt {attempt} url={} proxy={:?}",
log_url(current.url()),
proxy_idx.map(|idx| inner.proxies.redacted(idx))
);
match client.execute(current).await {
Ok(response) => {
let status = response.status();
let blamed_proxy = proxy_idx.filter(|_| is_proxy_failure_status(status));
match (blamed_proxy, proxy_idx) {
(Some(idx), _) => {
trace_log!(
"proxy {} answered {status}: cooling it down",
inner.proxies.redacted(idx)
);
inner.proxies.mark_bad_index(idx, inner.proxy_cooldown);
}
(None, Some(idx)) => inner.proxies.mark_good_index(idx),
_ => {}
}
if is_last_attempt
|| !(blamed_proxy.is_some() || is_retryable_status(status, idempotent))
{
return Ok(response);
}
let delay = if let Some(idx) = blamed_proxy {
switch_delay(inner, attempt, idx)
} else {
match retry_after(response.headers()) {
Some(asked) if asked > inner.max_retry_after => {
trace_log!(
"server asked to wait {asked:?}, above max_retry_after: returning {status}"
);
return Ok(response);
}
Some(asked) => asked,
None => backoff_delay(attempt, inner.backoff_base, inner.backoff_max),
}
};
trace_log!("retrying after {delay:?}, status={status}");
drain(response).await;
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
Err(err) => {
let blamed_proxy = proxy_idx.filter(|_| is_transport_error(&err));
if let Some(idx) = blamed_proxy {
trace_log!(
"proxy {} failed ({}): cooling it down",
inner.proxies.redacted(idx),
error_kind(&err)
);
inner.proxies.mark_bad_index(idx, inner.proxy_cooldown);
}
if is_last_attempt || !should_retry_error(&err, idempotent) {
return Err(Error::Reqwest(err));
}
let delay = if let Some(idx) = blamed_proxy {
switch_delay(inner, attempt, idx)
} else {
backoff_delay(attempt, inner.backoff_base, inner.backoff_max)
};
trace_log!("retrying after {delay:?} ({} error)", error_kind(&err));
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
}
attempt = attempt.saturating_add(1);
}
}
}
#[derive(Debug)]
#[must_use = "RequestBuilder does nothing until you call `send` or `build`"]
pub struct RequestBuilder {
client: RotatingClient,
inner: reqwest::RequestBuilder,
}
impl RequestBuilder {
fn map(mut self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self {
self.inner = f(self.inner);
self
}
pub fn header<K, V>(self, key: K, value: V) -> Self
where
HeaderName: TryFrom<K>,
<HeaderName as TryFrom<K>>::Error: Into<http::Error>,
HeaderValue: TryFrom<V>,
<HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
{
self.map(|b| b.header(key, value))
}
pub fn headers(self, headers: HeaderMap) -> Self {
self.map(|b| b.headers(headers))
}
pub fn basic_auth<U, P>(self, username: U, password: Option<P>) -> Self
where
U: fmt::Display,
P: fmt::Display,
{
self.map(|b| b.basic_auth(username, password))
}
pub fn bearer_auth<T>(self, token: T) -> Self
where
T: fmt::Display,
{
self.map(|b| b.bearer_auth(token))
}
pub fn body<T: Into<reqwest::Body>>(self, body: T) -> Self {
self.map(|b| b.body(body))
}
pub fn timeout(self, timeout: Duration) -> Self {
self.map(|b| b.timeout(timeout))
}
pub fn version(self, version: reqwest::Version) -> Self {
self.map(|b| b.version(version))
}
pub fn query<T: serde::Serialize + ?Sized>(self, query: &T) -> Self {
self.map(|b| b.query(query))
}
pub fn form<T: serde::Serialize + ?Sized>(self, form: &T) -> Self {
self.map(|b| b.form(form))
}
#[cfg(feature = "json")]
pub fn json<T: serde::Serialize + ?Sized>(self, json: &T) -> Self {
self.map(|b| b.json(json))
}
#[cfg(feature = "multipart")]
pub fn multipart(self, form: reqwest::multipart::Form) -> Self {
self.map(|b| b.multipart(form))
}
pub fn build(self) -> Result<Request, Error> {
Ok(self.inner.build()?)
}
pub async fn send(self) -> Result<Response, Error> {
self.client.send(self.inner).await
}
pub fn into_inner(self) -> reqwest::RequestBuilder {
self.inner
}
}
fn switch_delay(inner: &Inner, attempt: u32, idx: usize) -> Duration {
if inner.proxies.any_healthy_except(idx) {
Duration::ZERO
} else {
backoff_delay(attempt, inner.backoff_base, inner.backoff_max)
}
}
#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
fn log_url(url: &reqwest::Url) -> String {
use std::fmt::Write;
let mut out = format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""));
if let Some(port) = url.port() {
let _ = write!(out, ":{port}");
}
out.push_str(url.path());
out
}
async fn drain(mut response: Response) {
let mut budget = DRAIN_BUDGET;
while budget > 0 {
match response.chunk().await {
Ok(Some(chunk)) => budget = spend(budget, chunk.len()),
_ => break,
}
}
}
fn spend(budget: usize, chunk_len: usize) -> usize {
budget.saturating_sub(chunk_len.max(1))
}
#[derive(Default)]
pub struct RotatingClientBuilder {
proxies: Vec<String>,
proxy_list: Option<ProxyList>,
rate_limit: Option<Duration>,
retries: Option<u32>,
backoff_base: Option<Duration>,
backoff_max: Option<Duration>,
max_retry_after: Option<Duration>,
proxy_cooldown: Option<Duration>,
user_agent: Option<String>,
timeout: Option<Duration>,
connect_timeout: Option<Duration>,
configure: Option<Box<ConfigureFn>>,
}
impl fmt::Debug for RotatingClientBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RotatingClientBuilder")
.field(
"proxies",
&self
.proxies
.iter()
.map(|url| crate::proxy::redact_userinfo(url))
.collect::<Vec<_>>(),
)
.field("proxy_list", &self.proxy_list)
.field("rate_limit", &self.rate_limit)
.field("retries", &self.retries)
.field("backoff_base", &self.backoff_base)
.field("backoff_max", &self.backoff_max)
.field("max_retry_after", &self.max_retry_after)
.field("proxy_cooldown", &self.proxy_cooldown)
.field("user_agent", &self.user_agent)
.field("timeout", &self.timeout)
.field("connect_timeout", &self.connect_timeout)
.field("configure", &self.configure.as_ref().map(|_| "<fn>"))
.finish()
}
}
impl RotatingClientBuilder {
#[must_use]
pub fn proxies<I, S>(mut self, proxies: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.proxies = proxies.into_iter().map(|s| s.as_ref().to_owned()).collect();
self
}
#[must_use]
pub fn proxy_list(mut self, proxy_list: ProxyList) -> Self {
self.proxy_list = Some(proxy_list);
self
}
#[must_use]
pub const fn rate_limit(mut self, interval: Duration) -> Self {
self.rate_limit = Some(interval);
self
}
#[must_use]
pub const fn retries(mut self, retries: u32) -> Self {
self.retries = Some(retries);
self
}
#[must_use]
pub const fn backoff(mut self, base: Duration, max: Duration) -> Self {
self.backoff_base = Some(base);
self.backoff_max = Some(max);
self
}
#[must_use]
pub const fn max_retry_after(mut self, max: Duration) -> Self {
self.max_retry_after = Some(max);
self
}
#[must_use]
pub const fn proxy_cooldown(mut self, cooldown: Duration) -> Self {
self.proxy_cooldown = Some(cooldown);
self
}
#[must_use]
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
#[must_use]
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
#[must_use]
pub fn configure<F>(mut self, configure: F) -> Self
where
F: Fn(reqwest::ClientBuilder) -> reqwest::ClientBuilder + Send + Sync + 'static,
{
self.configure = Some(Box::new(configure));
self
}
pub fn build(self) -> Result<RotatingClient, Error> {
let proxies = match self.proxy_list {
Some(list) => list,
None => ProxyList::new(&self.proxies)?,
};
let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
let connect_timeout = self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT);
let build_client = |proxy_url: Option<&str>| -> Result<reqwest::Client, Error> {
let mut builder = reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(connect_timeout);
if let Some(user_agent) = &self.user_agent {
builder = builder.user_agent(user_agent.as_str());
}
match proxy_url {
Some(proxy_url) => {
let proxy = reqwest::Proxy::all(proxy_url).map_err(|e| {
Error::InvalidProxy {
proxy: crate::proxy::redact_userinfo(proxy_url),
source: Box::new(e.without_url()),
}
})?;
builder = builder.proxy(proxy);
}
None => builder = builder.no_proxy(),
}
if let Some(configure) = &self.configure {
builder = configure(builder);
}
builder.build().map_err(Error::Build)
};
let direct_client = build_client(None)?;
let proxy_clients = proxies
.as_slice()
.iter()
.map(|proxy_url| build_client(Some(proxy_url)))
.collect::<Result<Vec<_>, _>>()?;
Ok(RotatingClient {
inner: Arc::new(Inner {
direct_client,
proxy_clients,
proxies,
rate_limiter: RateLimiter::new(self.rate_limit),
retries: self.retries.unwrap_or(DEFAULT_RETRIES),
backoff_base: self
.backoff_base
.unwrap_or(DEFAULT_BACKOFF_BASE)
.min(crate::MAX_DURATION),
backoff_max: self
.backoff_max
.unwrap_or(DEFAULT_BACKOFF_MAX)
.min(crate::MAX_DURATION),
max_retry_after: self
.max_retry_after
.unwrap_or(DEFAULT_MAX_RETRY_AFTER)
.min(crate::MAX_DURATION),
proxy_cooldown: self.proxy_cooldown.unwrap_or(DEFAULT_PROXY_COOLDOWN),
}),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_base_and_max_are_clamped_to_a_year() {
let client = RotatingClient::builder()
.backoff(Duration::MAX, Duration::MAX)
.build()
.unwrap();
assert_eq!(client.inner.backoff_base, crate::MAX_DURATION);
assert_eq!(client.inner.backoff_max, crate::MAX_DURATION);
}
#[test]
fn builder_debug_hides_credentials() {
let debug = format!(
"{:?}",
RotatingClient::builder().proxies([
"user:pass@proxy.example:3128",
"http://user:p@ss@proxy.example:3128",
])
);
assert!(!debug.contains("pass"), "{debug}");
assert!(!debug.contains("ss@proxy"), "{debug}");
assert!(debug.contains("***@proxy.example:3128"), "{debug}");
}
#[test]
fn client_debug_hides_proxy_credentials() {
let client = RotatingClient::builder()
.proxies(["http://alice:s3cretpw@proxy.example:3128"])
.build()
.unwrap();
let debug = format!("{client:?}");
assert!(!debug.contains("s3cretpw"), "{debug}");
assert!(!debug.contains("YWxpY2U6czNjcmV0cHc="), "{debug}"); assert!(debug.contains("***@proxy.example:3128"), "{debug}");
}
#[tokio::test]
async fn proxy_failure_error_chain_hides_credentials() {
let client = RotatingClient::builder()
.proxies(["http://alice:s3cretpw@127.0.0.1:1"])
.retries(0)
.build()
.unwrap();
let err = client.get("http://example.invalid/").await.unwrap_err();
let mut chain = err.to_string();
let mut source = std::error::Error::source(&err);
while let Some(e) = source {
chain.push_str(" <- ");
chain.push_str(&e.to_string());
source = e.source();
}
assert!(!chain.contains("s3cretpw"), "{chain}");
assert!(!chain.contains("YWxpY2U6czNjcmV0cHc="), "{chain}"); assert!(!chain.contains("alice"), "{chain}");
}
#[test]
fn proxies_accepts_a_slice_of_str_refs() {
let proxies: Vec<&str> = vec!["http://a", "http://b"];
let client = RotatingClient::builder().proxies(&proxies).build().unwrap();
assert_eq!(client.proxies().len(), 2);
assert_eq!(proxies.len(), 2);
}
#[test]
fn max_retry_after_is_clamped_to_a_year() {
let client = RotatingClient::builder()
.max_retry_after(Duration::MAX)
.build()
.unwrap();
assert_eq!(client.inner.max_retry_after, crate::MAX_DURATION);
}
#[test]
fn drain_budget_always_shrinks() {
assert_eq!(spend(10, 0), 9);
assert_eq!(spend(10, 4), 6);
assert_eq!(spend(1, 0), 0);
assert_eq!(spend(3, 10), 0);
}
#[test]
fn log_url_keeps_only_scheme_host_port_path() {
assert_eq!(
log_url(&reqwest::Url::parse("http://u:p@h:8080/v1/data?api_key=SECRET#f").unwrap()),
"http://h:8080/v1/data"
);
assert_eq!(
log_url(&reqwest::Url::parse("http://h/path").unwrap()),
"http://h/path"
);
assert_eq!(
log_url(&reqwest::Url::parse("https://h:443/").unwrap()),
"https://h/"
);
assert_eq!(
log_url(&reqwest::Url::parse("http://[::1]:8080/p").unwrap()),
"http://[::1]:8080/p"
);
}
}