use std::time::Duration;
use super::types::{ChallengeKind, ChallengeSignal};
const DEFAULT_MAX_ATTEMPTS: u32 = 3;
const DEFAULT_BASE_DELAY: Duration = Duration::from_secs(2);
const DEFAULT_MAX_DELAY: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Proceed,
Wait {
delay: Duration,
attempt: u32,
},
RotateProxy {
attempt: u32,
},
Fail {
reason: String,
},
}
#[derive(Debug, Clone, Copy)]
pub struct MitigationPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub can_rotate_proxy: bool,
}
impl Default for MitigationPolicy {
fn default() -> Self {
Self {
max_attempts: DEFAULT_MAX_ATTEMPTS,
base_delay: DEFAULT_BASE_DELAY,
max_delay: DEFAULT_MAX_DELAY,
can_rotate_proxy: false,
}
}
}
impl MitigationPolicy {
pub fn new(max_attempts: u32) -> Self {
Self {
max_attempts,
..Self::default()
}
}
pub fn with_proxy_rotation(mut self, can_rotate_proxy: bool) -> Self {
self.can_rotate_proxy = can_rotate_proxy;
self
}
pub fn decide(&self, signal: &ChallengeSignal, attempt: u32) -> Action {
match signal.kind {
ChallengeKind::None => Action::Proceed,
ChallengeKind::AccessDenied => {
if self.can_rotate_proxy && attempt < self.max_attempts {
Action::RotateProxy {
attempt: attempt + 1,
}
} else {
Action::Fail {
reason: "access denied (e.g. Cloudflare error 1020); \
no healthy proxy left to rotate to"
.to_string(),
}
}
}
kind => {
if attempt >= self.max_attempts {
return Action::Fail {
reason: format!(
"challenge {kind:?} still present after {} attempt(s)",
self.max_attempts
),
};
}
Action::Wait {
delay: self.backoff(attempt),
attempt: attempt + 1,
}
}
}
}
fn backoff(&self, attempt: u32) -> Duration {
let factor = 2u64.saturating_pow(attempt);
let secs = self
.base_delay
.as_secs()
.saturating_mul(factor)
.min(self.max_delay.as_secs());
Duration::from_secs(secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::challenge::{ChallengeSignal, Confidence};
fn signal(kind: ChallengeKind) -> ChallengeSignal {
ChallengeSignal {
kind,
confidence: Confidence::High,
evidence: vec![],
}
}
#[test]
fn decide_no_challenge_proceeds() {
let policy = MitigationPolicy::default();
assert_eq!(
policy.decide(&signal(ChallengeKind::None), 0),
Action::Proceed
);
}
#[test]
fn decide_access_denied_fails_without_rotation() {
let policy = MitigationPolicy::default(); assert!(matches!(
policy.decide(&signal(ChallengeKind::AccessDenied), 0),
Action::Fail { .. }
));
}
#[test]
fn decide_access_denied_rotates_when_available() {
let policy = MitigationPolicy::new(3).with_proxy_rotation(true);
assert_eq!(
policy.decide(&signal(ChallengeKind::AccessDenied), 0),
Action::RotateProxy { attempt: 1 }
);
}
#[test]
fn decide_access_denied_fails_once_rotation_budget_exhausted() {
let policy = MitigationPolicy::new(2).with_proxy_rotation(true);
assert!(matches!(
policy.decide(&signal(ChallengeKind::AccessDenied), 2),
Action::Fail { .. }
));
}
#[test]
fn decide_js_challenge_waits_while_budget_remains() {
let policy = MitigationPolicy::new(3);
match policy.decide(&signal(ChallengeKind::JsChallenge), 0) {
Action::Wait { delay, attempt } => {
assert_eq!(delay, Duration::from_secs(2)); assert_eq!(attempt, 1);
}
other => panic!("expected Wait, got {other:?}"),
}
}
#[test]
fn decide_fails_once_attempts_exhausted() {
let policy = MitigationPolicy::new(2);
assert!(matches!(
policy.decide(&signal(ChallengeKind::Turnstile), 2),
Action::Fail { .. }
));
}
#[test]
fn backoff_is_exponential_and_capped() {
let policy = MitigationPolicy {
max_attempts: 10,
base_delay: Duration::from_secs(2),
max_delay: Duration::from_secs(15),
can_rotate_proxy: false,
};
assert_eq!(policy.backoff(0), Duration::from_secs(2));
assert_eq!(policy.backoff(1), Duration::from_secs(4));
assert_eq!(policy.backoff(2), Duration::from_secs(8));
assert_eq!(policy.backoff(3), Duration::from_secs(15));
assert_eq!(policy.backoff(64), Duration::from_secs(15));
}
#[test]
fn new_overrides_attempts_keeps_default_delays() {
let policy = MitigationPolicy::new(7);
assert_eq!(policy.max_attempts, 7);
assert_eq!(policy.base_delay, DEFAULT_BASE_DELAY);
assert_eq!(policy.max_delay, DEFAULT_MAX_DELAY);
}
}