Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-loadbench/src/http.rs

//! **The HTTP probe.** One `ureq` agent per probe, shared across all workers so
//! the estate sees connection reuse the way a real browser fleet produces it,
//! and so the bench measures the SERVER rather than its own TLS handshakes.
//!
//! `http_status_as_error(false)` is deliberate: a 401 and a 503 are answers this
//! bench wants to read the body of, not errors it wants to unwind through.

use std::io::ErrorKind;
use std::time::{Duration, Instant};

use crate::outcome::{classify_http, classify_transport, Outcome};
use crate::safety::SafeGet;
use crate::stats::Sample;

/// How much of a body to read before deciding. Enough to see the console's
/// `{"code":"maintenance"}` and to have actually drained a small answer;
/// a cap so a 36 KB login page does not dominate the latency of a probe that
/// is about the server's think time. Bodies larger than this are read in full
/// anyway — see [`Probe::drain_all`].
const PEEK: usize = 512;

/// One benched endpoint.
pub struct Probe {
    #[allow(dead_code)]
    // the catalogue Spec is what the report prints; this is the probe's own copy
    pub name: &'static str,
    pub target: SafeGet,
    /// The status class this endpoint answers with when the estate is healthy.
    /// For a route behind the console's `require_auth` layer that is 401, and
    /// counting those 401s as failures would make a healthy console look down.
    #[allow(dead_code)]
    pub expect: Outcome,
    /// Read the whole body, not just [`PEEK`]. True for the pages whose SIZE is
    /// part of what is being measured (the 36 KB SPA shell).
    pub drain_all: bool,
    agent: ureq::Agent,
}

impl Probe {
    pub fn new(
        name: &'static str,
        url: &str,
        expect: Outcome,
        drain_all: bool,
        timeout: Duration,
        max_in_flight: usize,
    ) -> Result<Probe, String> {
        let target = SafeGet::new(url)?;
        let cfg = ureq::Agent::config_builder()
            // A 401 is an answer, not an error.
            .http_status_as_error(false)
            // The per-request budget. Exceeding it must be an Outcome::Timeout.
            .timeout_global(Some(timeout))
            // One pooled connection per worker: more would let the bench hold
            // sockets open that no worker is using, which is load the estate
            // pays for and the numbers do not describe.
            .max_idle_connections(max_in_flight)
            .max_idle_connections_per_host(max_in_flight)
            // A redirect chased under load measures the wrong endpoint.
            .max_redirects(0)
            .max_redirects_will_error(false)
            .user_agent("gunnar-loadbench/0.0 (+read-only)")
            .build();
        Ok(Probe {
            name,
            target,
            expect,
            drain_all,
            agent: ureq::Agent::new_with_config(cfg),
        })
    }

    /// Issue one request. Never panics, never unwraps a network result: every
    /// failure becomes a counted [`Outcome`].
    pub fn once(&self, released: Instant) -> Sample {
        match self.agent.get(self.target.url()).call() {
            Ok(mut res) => {
                let status = res.status().as_u16();
                // The body is read INSIDE the timed span on purpose: a server
                // that sends headers fast and then stalls is slow, and a bench
                // that stops the clock at the status line would not say so.
                let body = if self.drain_all {
                    res.body_mut().read_to_string()
                } else {
                    res.body_mut()
                        .with_config()
                        .limit(PEEK as u64)
                        .read_to_string()
                };
                let dt = released.elapsed().as_nanos() as u64;
                match body {
                    Ok(b) => Sample {
                        latency_ns: dt,
                        late_ns: 0,
                        bytes: b.len().min(u32::MAX as usize) as u32,
                        outcome: classify_http(status, &b),
                    },
                    // Headers arrived, the body did not. That is a mid-flight
                    // death and belongs in its own bucket, not in `ok`.
                    Err(e) => Sample::refused(err_outcome(&e), dt, 0),
                }
            }
            Err(e) => Sample::refused(err_outcome(&e), released.elapsed().as_nanos() as u64, 0),
        }
    }

    /// Did this endpoint answer the way a healthy estate answers it?
    #[allow(dead_code)] // the verdict does this by bucket key; kept as the single-sample form
    pub fn is_expected(&self, o: Outcome) -> bool {
        o == self.expect
    }
}

/// Map a `ureq` error onto the taxonomy. The `Timeout` variant is explicit
/// because `timeout_global` produces it rather than an `io::ErrorKind::TimedOut`.
fn err_outcome(e: &ureq::Error) -> Outcome {
    match e {
        ureq::Error::Timeout(_) => Outcome::Timeout,
        ureq::Error::HostNotFound => Outcome::Dns,
        ureq::Error::Tls(_) => Outcome::Tls,
        ureq::Error::Io(io) => classify_transport(Some(io.kind()), &io.to_string()),
        ureq::Error::ConnectionFailed => classify_transport(None::<ErrorKind>, "connection failed"),
        ureq::Error::StatusCode(s) => classify_http(*s, ""),
        other => classify_transport(None::<ErrorKind>, &other.to_string()),
    }
}

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

    fn p(url: &str) -> Result<Probe, String> {
        Probe::new("t", url, Outcome::Ok, false, Duration::from_secs(1), 2)
    }

    #[test]
    fn a_probe_cannot_be_built_for_a_money_url() {
        assert!(p("https://buy.stripe.com/x").is_err());
        assert!(p("https://gunnar.rs/signup").is_err());
    }

    #[test]
    fn a_read_endpoint_builds() {
        assert!(p("https://gunnar.rs/health").is_ok());
    }

    #[test]
    fn a_ureq_timeout_is_a_timeout_and_not_an_other() {
        let e = ureq::Error::Timeout(ureq::Timeout::Global);
        assert_eq!(err_outcome(&e), Outcome::Timeout);
    }

    #[test]
    fn a_missing_host_is_dns() {
        assert_eq!(err_outcome(&ureq::Error::HostNotFound), Outcome::Dns);
    }

    #[test]
    fn a_reset_socket_is_a_reset() {
        let e = ureq::Error::Io(std::io::Error::new(
            std::io::ErrorKind::ConnectionReset,
            "reset",
        ));
        assert_eq!(err_outcome(&e), Outcome::ConnectionReset);
    }
}