use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Outcome {
Success,
Challenged,
Blocked,
RateLimited,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DomainState {
pub host: String,
pub last_outcome: Option<Outcome>,
pub last_proxy: Option<String>,
pub successes: u32,
pub failures: u32,
pub cooldown_until: Option<u64>,
pub updated_at: u64,
}
impl DomainState {
pub fn new(host: impl Into<String>) -> Self {
Self {
host: host.into(),
last_outcome: None,
last_proxy: None,
successes: 0,
failures: 0,
cooldown_until: None,
updated_at: 0,
}
}
pub fn record(
&self,
outcome: Outcome,
proxy: Option<String>,
now: u64,
rate_limit_cooldown: Duration,
) -> Self {
let mut next = self.clone();
next.last_outcome = Some(outcome);
if proxy.is_some() {
next.last_proxy = proxy;
}
next.updated_at = now;
match outcome {
Outcome::Success | Outcome::Challenged => {
next.successes = next.successes.saturating_add(1);
next.cooldown_until = None;
}
Outcome::RateLimited => {
next.failures = next.failures.saturating_add(1);
next.cooldown_until = Some(now.saturating_add(rate_limit_cooldown.as_secs()));
}
Outcome::Blocked => {
next.failures = next.failures.saturating_add(1);
next.cooldown_until = None;
}
}
next
}
pub fn in_cooldown(&self, now: u64) -> bool {
self.cooldown_until.is_some_and(|until| now < until)
}
pub fn cooldown_remaining(&self, now: u64) -> Option<Duration> {
self.cooldown_until
.and_then(|until| until.checked_sub(now))
.filter(|&secs| secs > 0)
.map(Duration::from_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_success_increments_and_clears_cooldown() {
let s = DomainState::new("example.com").record(
Outcome::RateLimited,
None,
100,
Duration::from_secs(60),
);
assert!(s.in_cooldown(120));
let s = s.record(
Outcome::Success,
Some("http://p:1".into()),
200,
Duration::ZERO,
);
assert_eq!(s.successes, 1);
assert_eq!(s.last_outcome, Some(Outcome::Success));
assert_eq!(s.last_proxy.as_deref(), Some("http://p:1"));
assert!(!s.in_cooldown(200));
assert_eq!(s.cooldown_until, None);
}
#[test]
fn record_rate_limited_sets_cooldown() {
let s = DomainState::new("h").record(
Outcome::RateLimited,
None,
1_000,
Duration::from_secs(30),
);
assert_eq!(s.failures, 1);
assert!(s.in_cooldown(1_029));
assert!(!s.in_cooldown(1_030));
assert_eq!(s.cooldown_remaining(1_010), Some(Duration::from_secs(20)));
assert_eq!(s.cooldown_remaining(1_030), None);
}
#[test]
fn record_keeps_proxy_when_none_passed() {
let s = DomainState::new("h")
.record(Outcome::Success, Some("http://a".into()), 1, Duration::ZERO)
.record(Outcome::Blocked, None, 2, Duration::ZERO);
assert_eq!(s.last_proxy.as_deref(), Some("http://a"));
assert_eq!(s.failures, 1);
assert_eq!(s.last_outcome, Some(Outcome::Blocked));
}
#[test]
fn no_cooldown_by_default() {
let s = DomainState::new("h");
assert!(!s.in_cooldown(0));
assert_eq!(s.cooldown_remaining(0), None);
}
#[test]
fn challenged_counts_as_success_and_clears_cooldown() {
let s =
DomainState::new("h").record(Outcome::RateLimited, None, 100, Duration::from_secs(60));
assert!(s.in_cooldown(120));
let s = s.record(Outcome::Challenged, None, 200, Duration::ZERO);
assert_eq!(s.successes, 1);
assert_eq!(s.failures, 1); assert_eq!(s.last_outcome, Some(Outcome::Challenged));
assert!(!s.in_cooldown(200));
}
#[test]
fn blocked_counts_as_failure_and_clears_stale_cooldown() {
let s =
DomainState::new("h").record(Outcome::RateLimited, None, 100, Duration::from_secs(60));
assert!(s.in_cooldown(120));
let s = s.record(Outcome::Blocked, None, 130, Duration::ZERO);
assert_eq!(s.failures, 2);
assert_eq!(s.successes, 0);
assert!(!s.in_cooldown(130));
assert_eq!(s.cooldown_until, None);
}
}