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);
}
}