use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use tokio::time::Instant;
pub const MIN_INTERVAL: Duration = Duration::from_millis(2500);
pub const BACKOFF: Duration = Duration::from_secs(60);
pub const SEARCH_DEADLINE: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
BackingOff(Duration),
PastDeadline,
}
pub struct SearchGate {
interval: Duration,
backoff: Duration,
state: Mutex<GateState>,
}
#[derive(Default)]
struct GateState {
next_turn: Option<Instant>,
backoff_until: Option<Instant>,
}
impl GateState {
fn backoff_left(&self, now: Instant) -> Option<Duration> {
let left = self.backoff_until?.saturating_duration_since(now);
(!left.is_zero()).then_some(left)
}
}
impl SearchGate {
pub fn new(interval: Duration, backoff: Duration) -> Self {
Self {
interval,
backoff,
state: Mutex::new(GateState::default()),
}
}
pub fn duckduckgo() -> Arc<SearchGate> {
static GATE: OnceLock<Arc<SearchGate>> = OnceLock::new();
GATE.get_or_init(|| Arc::new(SearchGate::new(MIN_INTERVAL, BACKOFF)))
.clone()
}
pub async fn take_turn(&self, deadline: Instant) -> Result<(), Refusal> {
let turn = {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
let now = Instant::now();
if let Some(left) = state.backoff_left(now) {
return Err(Refusal::BackingOff(left));
}
let turn = state.next_turn.map_or(now, |next| next.max(now));
if turn > deadline {
return Err(Refusal::PastDeadline);
}
state.next_turn = Some(turn + self.interval);
turn
};
tokio::time::sleep_until(turn).await;
match self.backoff_left() {
Some(left) => Err(Refusal::BackingOff(left)),
None => Ok(()),
}
}
pub fn throttled(&self) {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
state.backoff_until = Some(Instant::now() + self.backoff);
}
pub fn backoff_left(&self) -> Option<Duration> {
let state = self.state.lock().unwrap_or_else(|p| p.into_inner());
state.backoff_left(Instant::now())
}
}
pub fn time_left(deadline: Instant, limit: Duration) -> Duration {
deadline
.saturating_duration_since(Instant::now())
.min(limit)
}
pub const THROTTLE_HINT: &str =
", which it does when it throttles or blocks automated searches; try again later";
pub fn is_throttle_status(status: u16) -> bool {
matches!(status, 202 | 403 | 429)
}
pub fn is_challenge_page(html: &str) -> bool {
html.contains("anomaly-modal") || html.contains("challenge-form")
}
pub fn is_ad_link(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|url| {
url.scheme() == "https"
&& url
.host_str()
.is_some_and(|host| host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
&& url.path() == "/y.js"
})
}
#[cfg(test)]
mod tests {
use super::*;
fn far() -> Instant {
Instant::now() + Duration::from_secs(3600)
}
#[tokio::test(start_paused = true)]
async fn requests_are_spaced_one_interval_apart() {
let gate = Arc::new(SearchGate::new(Duration::from_secs(2), BACKOFF));
let start = Instant::now();
gate.take_turn(far()).await.unwrap();
gate.take_turn(far()).await.unwrap();
assert_eq!(start.elapsed(), Duration::from_secs(2));
let start = Instant::now();
let waits = (0..3).map(|_| {
let gate = gate.clone();
tokio::spawn(async move {
gate.take_turn(far()).await.unwrap();
start.elapsed()
})
});
let mut done = Vec::new();
for wait in waits {
done.push(wait.await.unwrap());
}
done.sort();
assert_eq!(
done,
[2, 4, 6].map(Duration::from_secs),
"the first of these waits for the interval after the last request"
);
}
#[tokio::test(start_paused = true)]
async fn a_burst_of_searches_goes_out_spaced_by_the_gate() {
let gate = Arc::new(SearchGate::new(MIN_INTERVAL, BACKOFF));
let sends = (0..10).map(|_| {
let gate = gate.clone();
tokio::spawn(async move {
gate.take_turn(far()).await.unwrap();
Instant::now()
})
});
let mut sent = Vec::new();
for send in sends {
sent.push(send.await.unwrap());
}
sent.sort();
for pair in sent.windows(2) {
assert!(pair[1] - pair[0] >= MIN_INTERVAL, "{:?}", pair[1] - pair[0]);
}
}
#[tokio::test(start_paused = true)]
async fn a_request_after_a_quiet_spell_is_not_delayed() {
let gate = SearchGate::new(Duration::from_secs(2), BACKOFF);
gate.take_turn(far()).await.unwrap();
tokio::time::sleep(Duration::from_secs(10)).await;
let start = Instant::now();
gate.take_turn(far()).await.unwrap();
assert_eq!(start.elapsed(), Duration::ZERO);
}
#[tokio::test(start_paused = true)]
async fn a_throttled_request_starts_the_backoff_window() {
let gate = SearchGate::new(MIN_INTERVAL, Duration::from_secs(60));
assert_eq!(gate.backoff_left(), None);
gate.throttled();
assert_eq!(gate.backoff_left(), Some(Duration::from_secs(60)));
assert_eq!(
gate.take_turn(far()).await,
Err(Refusal::BackingOff(Duration::from_secs(60)))
);
tokio::time::sleep(Duration::from_secs(59)).await;
assert_eq!(gate.backoff_left(), Some(Duration::from_secs(1)));
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(gate.backoff_left(), None);
assert_eq!(gate.take_turn(far()).await, Ok(()));
}
#[tokio::test(start_paused = true)]
async fn callers_queued_behind_a_throttled_request_do_not_send() {
let gate = Arc::new(SearchGate::new(Duration::from_secs(2), BACKOFF));
let turns: Vec<_> = (0..3)
.map(|_| {
let gate = gate.clone();
tokio::spawn(async move { gate.take_turn(far()).await })
})
.collect();
tokio::task::yield_now().await;
let mut turns = turns.into_iter();
assert_eq!(turns.next().unwrap().await.unwrap(), Ok(()));
gate.throttled();
for queued in turns {
assert!(
matches!(queued.await.unwrap(), Err(Refusal::BackingOff(_))),
"a queued caller sent during the back-off"
);
}
}
#[tokio::test(start_paused = true)]
async fn a_turn_past_the_deadline_is_refused_at_once() {
let gate = SearchGate::new(Duration::from_secs(5), BACKOFF);
gate.take_turn(far()).await.unwrap();
let start = Instant::now();
let deadline = start + Duration::from_secs(4);
assert_eq!(gate.take_turn(deadline).await, Err(Refusal::PastDeadline));
assert_eq!(start.elapsed(), Duration::ZERO);
gate.take_turn(far()).await.unwrap();
assert_eq!(start.elapsed(), Duration::from_secs(5));
}
#[tokio::test(start_paused = true)]
async fn a_request_gets_what_is_left_of_the_deadline() {
let deadline = Instant::now() + Duration::from_secs(10);
assert_eq!(
time_left(deadline, Duration::from_secs(30)),
Duration::from_secs(10)
);
assert_eq!(
time_left(deadline, Duration::from_secs(4)),
Duration::from_secs(4)
);
tokio::time::sleep(Duration::from_secs(11)).await;
assert_eq!(time_left(deadline, Duration::from_secs(30)), Duration::ZERO);
}
#[test]
fn ad_links_are_the_providers_click_tracker() {
for ad in [
"https://duckduckgo.com/y.js?ad_domain=abebooks.com&ad_provider=bingv7aa",
"https://www.duckduckgo.com/y.js?u3=x",
] {
assert!(is_ad_link(ad), "{ad}");
}
for result in [
"https://en.wikipedia.org/wiki/John_Adams",
"https://duckduckgo.com/about",
"https://example.com/y.js",
"https://duckduckgo.com.evil.example/y.js",
"http://duckduckgo.com/y.js",
"not a url",
] {
assert!(!is_ad_link(result), "{result}");
}
}
}