use crate::{
DriveCache,
cache_middleware::{CacheBust, CacheBypass},
};
use async_trait::async_trait;
use http::Extensions;
use rand::RngExt;
use reqwest::{Request, Response};
use reqwest_middleware::{Error, Middleware, Next};
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::time::{Duration, sleep};
#[derive(Clone, Debug)]
pub struct ThrottlePolicy {
pub base_delay_ms: u64,
pub adaptive_jitter_ms: u64,
pub max_concurrent: usize,
pub max_retries: usize,
}
impl Default for ThrottlePolicy {
fn default() -> Self {
Self {
base_delay_ms: 500, adaptive_jitter_ms: 250, max_concurrent: 5, max_retries: 3, }
}
}
pub struct DriveThrottleBackoff {
semaphore: Arc<Semaphore>,
policy: ThrottlePolicy,
cache: Option<Arc<DriveCache>>,
}
impl DriveThrottleBackoff {
pub fn new(policy: ThrottlePolicy, cache: Arc<DriveCache>) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(policy.max_concurrent)),
policy,
cache: Some(cache),
}
}
pub fn without_cache(policy: ThrottlePolicy) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(policy.max_concurrent)),
policy,
cache: None,
}
}
pub fn available_permits(&self) -> usize {
self.semaphore.available_permits()
}
}
#[async_trait]
impl Middleware for DriveThrottleBackoff {
async fn handle(
&self,
req: Request,
extensions: &mut Extensions,
next: Next<'_>,
) -> Result<Response, Error> {
let url = req.url().to_string();
let bypass_cache = extensions
.get::<CacheBypass>()
.map(|flag| flag.0)
.unwrap_or(false);
let bust_cache = extensions
.get::<CacheBust>()
.map(|flag| flag.0)
.unwrap_or(false);
let cache_key = format!("{} {}", req.method(), url);
if !bypass_cache
&& !bust_cache
&& let Some(cache) = &self.cache
{
if cache.is_cached(&req).await {
tracing::debug!("Using cache for: {}", &cache_key);
return next.run(req, extensions).await;
} else {
tracing::debug!("No cache found for: {}", &cache_key);
}
}
let custom_policy = extensions.get::<ThrottlePolicy>().cloned();
let policy = custom_policy.unwrap_or_else(|| self.policy.clone());
if self.semaphore.available_permits() == 0 {
tracing::debug!("Waiting for permit... ({} in use)", policy.max_concurrent);
}
let permit = self
.semaphore
.acquire()
.await
.map_err(|e| Error::Middleware(e.into()))?;
tracing::debug!(
"Permit granted: {} ({} permits left)",
cache_key,
self.semaphore.available_permits()
);
let _permit_guard = permit;
sleep(Duration::from_millis(policy.base_delay_ms)).await;
let mut attempt = 0;
loop {
let req_clone = req.try_clone().expect("Request cloning failed");
let result = next.clone().run(req_clone, extensions).await;
match result {
Ok(resp) if resp.status().is_success() => return Ok(resp),
result if attempt >= policy.max_retries => return result,
_ => {
attempt += 1;
let backoff_duration = {
let mut rng = rand::rng();
Duration::from_millis(
policy.base_delay_ms * 2u64.pow(attempt as u32)
+ rng.random_range(0..=policy.adaptive_jitter_ms),
)
};
tracing::debug!(
"Retry {}/{} for URL {} after {} ms",
attempt,
policy.max_retries,
url,
backoff_duration.as_millis()
);
sleep(backoff_duration).await;
if attempt >= policy.max_retries {
break;
}
}
}
}
next.run(req, extensions).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest_middleware::ClientBuilder;
#[test]
fn throttle_policy_default_values_are_stable() {
let policy = ThrottlePolicy::default();
assert_eq!(policy.base_delay_ms, 500);
assert_eq!(policy.adaptive_jitter_ms, 250);
assert_eq!(policy.max_concurrent, 5);
assert_eq!(policy.max_retries, 3);
}
#[tokio::test]
async fn closed_semaphore_returns_middleware_error() {
let throttle = Arc::new(DriveThrottleBackoff::without_cache(ThrottlePolicy {
base_delay_ms: 1,
adaptive_jitter_ms: 0,
max_concurrent: 1,
max_retries: 0,
}));
throttle.semaphore.close();
let client = ClientBuilder::new(reqwest::Client::new())
.with_arc(throttle)
.build();
let error = client
.get("https://example.test/closed-semaphore")
.send()
.await
.expect_err("closed semaphore should return middleware error");
assert!(
matches!(error, reqwest_middleware::Error::Middleware(_)),
"expected middleware error variant, got: {:?}",
error
);
}
}