ferrflow 7.10.4

Universal semantic versioning for monorepos and classic repos
Documentation
use std::time::Duration;

const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_GLOBAL_TIMEOUT: Duration = Duration::from_secs(120);

const GLOBAL_TIMEOUT_ENV: &str = "FERRFLOW_HTTP_TIMEOUT";

pub fn agent() -> ureq::Agent {
    agent_with_global_timeout(global_timeout())
}

fn agent_with_global_timeout(global: Duration) -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_connect(Some(DEFAULT_CONNECT_TIMEOUT))
        .timeout_recv_response(Some(DEFAULT_RESPONSE_TIMEOUT))
        .timeout_global(Some(global))
        .build()
        .into()
}

fn global_timeout() -> Duration {
    std::env::var(GLOBAL_TIMEOUT_ENV)
        .ok()
        .and_then(|raw| parse_global_timeout(&raw))
        .unwrap_or(DEFAULT_GLOBAL_TIMEOUT)
}

fn parse_global_timeout(raw: &str) -> Option<Duration> {
    let secs: u64 = raw.trim().parse().ok()?;
    (secs > 0).then(|| Duration::from_secs(secs))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_global_timeout_accepts_positive_seconds() {
        assert_eq!(parse_global_timeout("45"), Some(Duration::from_secs(45)));
        assert_eq!(
            parse_global_timeout("  600 "),
            Some(Duration::from_secs(600))
        );
    }

    #[test]
    fn parse_global_timeout_rejects_zero_and_garbage() {
        assert_eq!(parse_global_timeout("0"), None);
        assert_eq!(parse_global_timeout(""), None);
        assert_eq!(parse_global_timeout("30s"), None);
        assert_eq!(parse_global_timeout("-5"), None);
    }

    #[test]
    fn agent_config_has_connect_response_and_global_timeouts() {
        let agent = agent_with_global_timeout(Duration::from_secs(90));
        let timeouts = agent.config().timeouts();
        assert_eq!(timeouts.connect, Some(DEFAULT_CONNECT_TIMEOUT));
        assert_eq!(timeouts.recv_response, Some(DEFAULT_RESPONSE_TIMEOUT));
        assert_eq!(timeouts.global, Some(Duration::from_secs(90)));
    }

    #[test]
    fn stalled_peer_fails_within_the_global_budget() {
        use std::io::Read;
        use std::net::TcpListener;

        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
        let port = listener.local_addr().expect("addr").port();
        let handle = std::thread::spawn(move || {
            if let Ok((mut stream, _)) = listener.accept() {
                let mut sink = [0u8; 1024];
                let _ = stream.read(&mut sink);
                std::thread::sleep(Duration::from_secs(30));
            }
        });

        let agent = agent_with_global_timeout(Duration::from_secs(2));
        let started = std::time::Instant::now();
        let result = agent.get(&format!("http://127.0.0.1:{port}/")).call();
        let elapsed = started.elapsed();

        assert!(result.is_err(), "a stalled peer must not resolve");
        assert!(
            elapsed < Duration::from_secs(10),
            "call should abort near the 2s budget, took {elapsed:?}"
        );
        drop(handle);
    }
}