use std::collections::HashMap;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Duration;
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use tokio::sync::RwLock;
use crate::Config;
pub struct HostRateLimiter {
limiters: RwLock<HashMap<String, Arc<DefaultDirectRateLimiter>>>,
rps: u32,
}
impl HostRateLimiter {
pub fn new(rps_per_host: u32) -> Self {
Self {
limiters: RwLock::new(HashMap::new()),
rps: rps_per_host.max(1),
}
}
pub async fn until_ready(&self, host: &str) {
let limiter = self.get_or_create(host).await;
limiter.until_ready().await;
}
async fn get_or_create(&self, host: &str) -> Arc<DefaultDirectRateLimiter> {
{
let read = self.limiters.read().await;
if let Some(l) = read.get(host) {
return Arc::clone(l);
}
}
let mut write = self.limiters.write().await;
if let Some(l) = write.get(host) {
return Arc::clone(l);
}
let quota = Quota::per_second(
NonZeroU32::new(self.rps).expect("rps is clamped to >= 1 at construction"),
);
let limiter = Arc::new(RateLimiter::direct(quota));
write.insert(host.to_string(), Arc::clone(&limiter));
limiter
}
}
pub fn build_client(config: &Config, follow_redirects: bool) -> anyhow::Result<reqwest::Client> {
let redirect_policy = if follow_redirects {
reqwest::redirect::Policy::limited(10)
} else {
reqwest::redirect::Policy::none()
};
let mut headers = reqwest::header::HeaderMap::new();
if let Some(cookie_val) = &config.cookie {
if let Ok(hv) = reqwest::header::HeaderValue::from_str(cookie_val) {
headers.insert(reqwest::header::COOKIE, hv);
}
}
let mut builder = reqwest::Client::builder()
.timeout(config.timeout())
.user_agent(&config.user_agent)
.default_headers(headers)
.danger_accept_invalid_certs(true)
.redirect(redirect_policy)
.pool_max_idle_per_host(20)
.pool_idle_timeout(Duration::from_secs(90))
.tcp_keepalive(Duration::from_secs(30));
if let Some(proxy_url) = &config.proxy {
builder = builder.proxy(reqwest::Proxy::all(proxy_url)?);
}
Ok(builder.build()?)
}
pub async fn get_with_backoff(
client: &reqwest::Client,
url: &str,
rate_limiter: Option<&HostRateLimiter>,
) -> anyhow::Result<reqwest::Response> {
let host = {
let parsed = url::Url::parse(url)?;
parsed.host_str().unwrap_or(url).to_string()
};
const MAX_RETRIES: u32 = 4;
for attempt in 0..MAX_RETRIES {
if let Some(rl) = rate_limiter {
rl.until_ready(&host).await;
}
match client.get(url).send().await {
Ok(resp) if resp.status().as_u16() == 429 => {
let delay = Duration::from_millis(500 * 2u64.pow(attempt));
tracing::debug!(
url,
attempt,
delay_ms = delay.as_millis(),
"429 — backing off"
);
tokio::time::sleep(delay).await;
}
Ok(resp) => return Ok(resp),
Err(e) if attempt + 1 < MAX_RETRIES && e.is_timeout() => {
let delay = Duration::from_millis(200 * 2u64.pow(attempt));
tokio::time::sleep(delay).await;
}
Err(e) => return Err(e.into()),
}
}
anyhow::bail!("max retries exceeded for {}", url)
}