#!/usr/bin/env cargo run
use bytes::Bytes;
use clap::Parser;
use http::header::{HeaderMap, HeaderName, HeaderValue};
use http::StatusCode;
use http::Uri;
use http_stat::{
connect, format_duration, request, BenchmarkSummary, ConnectTo, HttpRequest, HttpStat, Lang,
ALPN_HTTP1, ALPN_HTTP2, ALPN_HTTP3,
};
use std::net::IpAddr;
use std::sync::Arc;
use tokio::fs;
#[cfg(target_env = "musl")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long)]
url: Option<String>,
#[arg(
short = 'H',
help = "set HTTP header; repeatable: -H 'Accept: ...' -H 'Range: ...'"
)]
headers: Vec<String>,
#[arg(short = '4', help = "resolve host to ipv4 only")]
ipv4: bool,
#[arg(short = '6', help = "resolve host to ipv6 only")]
ipv6: bool,
#[arg(short = 'k', help = "skip verify tls certificate")]
skip_verify: bool,
#[arg(short = 'o', help = "output file")]
output: Option<String>,
#[arg(short = 'L', help = "follow 30x redirects")]
follow_redirect: bool,
#[arg(short = 'X', help = "HTTP method to use (default GET)")]
method: Option<String>,
#[arg(
short = 'd',
long = "data",
help = "the body of a POST or PUT request; from file use @filename, from stdin use @-"
)]
data: Option<String>,
#[arg(help = "url to request")]
url_arg: Option<String>,
#[arg(
long = "resolve",
help = "resolve the request host to specific ip address (e.g. 1.2.3.4,1.2.3.5)"
)]
resolve: Option<String>,
#[arg(
long = "compressed",
help = "request compressed response: gzip, br, zstd"
)]
compressed: bool,
#[arg(long = "http3", help = "use http/3")]
http3: bool,
#[arg(long = "http2", help = "use http/2")]
http2: bool,
#[arg(long = "http1", help = "use http/1.1")]
http1: bool,
#[arg(
long = "alt-svc",
help = "if the response advertises HTTP/3 via Alt-Svc, retry once over h3"
)]
alt_svc: bool,
#[arg(
short = 's',
help = "silent mode, only output the connect address and result"
)]
silent: bool,
#[arg(
long = "dns-servers",
help = "dns server address to use, format: 8.8.8.8,8.8.4.4"
)]
dns_servers: Option<String>,
#[arg(short = 'v', long = "verbose", help = "verbose mode")]
verbose: bool,
#[arg(long = "pretty", help = "pretty mode")]
pretty: bool,
#[arg(long = "waterfall", help = "show timing as a waterfall bar chart")]
waterfall: bool,
#[arg(
long = "tcp-info",
help = "show kernel TCP_INFO stats (RTT, cwnd, retransmits); Linux + macOS"
)]
tcp_info: bool,
#[arg(long = "lang", help = "display language: en | zh (default: system)")]
lang: Option<String>,
#[arg(long = "timeout", help = "timeout")]
timeout: Option<String>,
#[arg(
long = "connect-timeout",
help = "max time for the connection phase only (DNS + TCP + TLS/QUIC), e.g. 5s"
)]
connect_timeout: Option<String>,
#[arg(
long = "max-time",
help = "overall time limit for the whole operation incl. body and redirects, e.g. 30s"
)]
max_time: Option<String>,
#[arg(
long = "retry",
help = "retry up to N times on transient failure (timeout, conn error, 408/429/5xx)"
)]
retry: Option<usize>,
#[arg(
long = "retry-delay",
help = "fixed delay between retries (e.g. 2s); default is exponential backoff"
)]
retry_delay: Option<String>,
#[arg(
long = "max-filesize",
help = "max response body size to buffer, e.g. 100MB (default 1GB); 0 = unlimited"
)]
max_filesize: Option<String>,
#[arg(
short = 'n',
long = "count",
help = "number of requests for benchmarking, show min/max/avg/p50/p95/p99 stats"
)]
count: Option<usize>,
#[arg(
short = 'K',
long = "reuse",
help = "reuse connection in benchmark mode (requires -n), test warm request performance"
)]
reuse: bool,
#[arg(
short = 'b',
long = "cookie",
help = "send cookies: 'name=value; name2=value2' or from file use @filename"
)]
cookie: Option<String>,
#[arg(long = "json", help = "output results as JSON for scripting and CI/CD")]
json: bool,
#[arg(
long = "connect-to",
help = "redirect HOST1:PORT1 to HOST2:PORT2 (repeatable); TLS SNI and Host header stay unchanged"
)]
connect_to: Vec<String>,
#[arg(
long = "proxy",
help = "proxy URL: http://host:port, https://host:port, socks5://host:port"
)]
proxy: Option<String>,
#[arg(long = "cert", help = "client certificate for mTLS (PEM file)")]
cert: Option<String>,
#[arg(long = "key", help = "client private key for mTLS (PEM file)")]
key: Option<String>,
#[arg(
long = "bind",
help = "bind to a specific local IP address (e.g. 192.168.1.100 or ::1)"
)]
bind: Option<String>,
#[arg(
long = "jq",
help = "filter JSON response body with a jq-style selector (e.g. \".items[].name\")"
)]
jq: Option<String>,
#[arg(
long = "include-header",
help = "only show these response headers (repeatable, case-insensitive)"
)]
include_header: Vec<String>,
#[arg(
long = "exclude-header",
help = "hide these response headers (repeatable, case-insensitive)"
)]
exclude_header: Vec<String>,
}
fn load_config() -> serde_json::Map<String, serde_json::Value> {
let path = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(|h| std::path::PathBuf::from(h).join(".httpstatrc"));
let Some(path) = path else {
return serde_json::Map::new();
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return serde_json::Map::new(),
};
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(serde_json::Value::Object(map)) => map,
Ok(_) => {
eprintln!("httpstat: ~/.httpstatrc must be a JSON object, ignoring");
serde_json::Map::new()
}
Err(e) => {
eprintln!("httpstat: failed to parse ~/.httpstatrc: {e}, ignoring");
serde_json::Map::new()
}
}
}
fn apply_config(args: &mut Args, cfg: &serde_json::Map<String, serde_json::Value>) {
macro_rules! cfg_bool {
($field:ident) => {
if !args.$field {
if let Some(true) = cfg.get(stringify!($field)).and_then(|v| v.as_bool()) {
args.$field = true;
}
}
};
}
macro_rules! cfg_opt_str {
($field:ident) => {
if args.$field.is_none() {
if let Some(s) = cfg.get(stringify!($field)).and_then(|v| v.as_str()) {
args.$field = Some(s.to_string());
}
}
};
}
cfg_bool!(compressed);
cfg_bool!(verbose);
cfg_bool!(pretty);
cfg_bool!(silent);
cfg_bool!(follow_redirect);
cfg_bool!(skip_verify);
cfg_bool!(http1);
cfg_bool!(http2);
cfg_bool!(http3);
cfg_bool!(json);
cfg_bool!(alt_svc);
cfg_opt_str!(dns_servers);
cfg_opt_str!(timeout);
cfg_opt_str!(connect_timeout);
cfg_opt_str!(max_time);
cfg_opt_str!(retry_delay);
cfg_opt_str!(max_filesize);
cfg_opt_str!(cookie);
cfg_opt_str!(output);
if args.retry.is_none() {
if let Some(n) = cfg.get("retry").and_then(|v| v.as_u64()) {
args.retry = Some(n as usize);
}
}
for key in &["headers", "include_header", "exclude_header"] {
if let Some(arr) = cfg.get(*key).and_then(|v| v.as_array()) {
let defaults: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect();
if !defaults.is_empty() {
let field = match *key {
"headers" => &mut args.headers,
"include_header" => &mut args.include_header,
_ => &mut args.exclude_header,
};
let mut merged = defaults;
merged.append(field);
*field = merged;
}
}
}
}
fn collect_cookies(stat: &HttpStat, existing: &str) -> String {
let mut cookies = std::collections::HashMap::new();
for pair in existing.split(';') {
let pair = pair.trim();
if let Some((name, value)) = pair.split_once('=') {
cookies.insert(name.trim().to_string(), value.trim().to_string());
}
}
if let Some(headers) = &stat.headers {
for value in headers.get_all(http::header::SET_COOKIE).iter() {
let value = value.to_str().unwrap_or_default();
let cookie_part = value.split(';').next().unwrap_or_default().trim();
if let Some((name, val)) = cookie_part.split_once('=') {
cookies.insert(name.trim().to_string(), val.trim().to_string());
}
}
}
cookies
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; ")
}
fn redirect_downgrades_to_get(status: StatusCode, method: &str) -> bool {
match status {
StatusCode::SEE_OTHER => !method.eq_ignore_ascii_case("HEAD"),
StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => method.eq_ignore_ascii_case("POST"),
_ => false,
}
}
fn resolve_redirect(base: &Uri, location: &str) -> Option<Uri> {
let location = location.trim();
if location.is_empty() {
return None;
}
if let Ok(uri) = location.parse::<Uri>() {
if uri.scheme().is_some() && uri.authority().is_some() {
return Some(uri);
}
}
let scheme = base.scheme_str()?;
let authority = base.authority()?.as_str();
if let Some(rest) = location.strip_prefix("//") {
format!("{scheme}://{rest}").parse().ok()
} else if location.starts_with('/') {
format!("{scheme}://{authority}{location}").parse().ok()
} else {
let base_path = base.path();
let dir = match base_path.rfind('/') {
Some(i) => &base_path[..=i],
None => "/",
};
format!("{scheme}://{authority}{dir}{location}")
.parse()
.ok()
}
}
async fn do_request(mut req: HttpRequest, follow_redirect: bool) -> HttpStat {
let mut stat = request(req.clone()).await;
if follow_redirect {
for _ in 0..10 {
let status = stat.status.unwrap_or(StatusCode::OK);
if ![
StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND,
StatusCode::SEE_OTHER,
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
]
.contains(&status)
{
break;
}
let location = stat
.headers
.as_ref()
.and_then(|header| header.get(http::header::LOCATION))
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
let Some(new_uri) = resolve_redirect(&req.uri, &location) else {
break;
};
let current_method = req.method.as_deref().unwrap_or("GET");
if redirect_downgrades_to_get(status, current_method) {
req.method = Some("GET".to_string());
req.body = None;
if let Some(h) = req.headers.as_mut() {
h.remove(http::header::CONTENT_TYPE);
h.remove(http::header::CONTENT_LENGTH);
h.remove(http::header::TRANSFER_ENCODING);
}
}
let same_host = req
.uri
.host()
.unwrap_or_default()
.eq_ignore_ascii_case(new_uri.host().unwrap_or_default());
if !same_host {
if let Some(h) = req.headers.as_mut() {
h.remove(http::header::AUTHORIZATION);
}
req.resolve = None;
}
if same_host {
let existing_cookie = req
.headers
.as_ref()
.and_then(|h| h.get(http::header::COOKIE))
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let merged = collect_cookies(&stat, &existing_cookie);
if !merged.is_empty() {
if let Ok(value) = merged.parse::<HeaderValue>() {
let header_map = req.headers.get_or_insert_with(HeaderMap::new);
header_map.insert(http::header::COOKIE, value);
}
}
} else if let Some(h) = req.headers.as_mut() {
h.remove(http::header::COOKIE);
}
req.uri = new_uri;
stat = request(req.clone()).await;
}
}
stat
}
fn benchmark_to_json(stats: &[HttpStat], connect_stat: Option<&HttpStat>) -> serde_json::Value {
let dur_us = |d: Option<std::time::Duration>| -> serde_json::Value {
d.map_or(serde_json::Value::Null, |d| {
serde_json::json!(d.as_micros() as u64)
})
};
let calc = |f: fn(&HttpStat) -> Option<std::time::Duration>| -> Vec<std::time::Duration> {
let mut v: Vec<std::time::Duration> = stats.iter().filter_map(f).collect();
v.sort();
v
};
let stat_obj = |sorted: &[std::time::Duration]| -> serde_json::Value {
if sorted.is_empty() {
return serde_json::Value::Null;
}
let sum: std::time::Duration = sorted.iter().sum();
let avg = sum / sorted.len() as u32;
let p = |pct: f64| -> u64 {
let idx = ((pct * sorted.len() as f64).ceil() as usize)
.saturating_sub(1)
.min(sorted.len() - 1);
sorted[idx].as_micros() as u64
};
serde_json::json!({
"min_us": sorted.first().unwrap().as_micros() as u64,
"max_us": sorted.last().unwrap().as_micros() as u64,
"avg_us": avg.as_micros() as u64,
"p50_us": p(0.5),
"p95_us": p(0.95),
"p99_us": p(0.99),
})
};
let success = stats.iter().filter(|s| s.is_success()).count();
let total = stats.len();
let mut obj = serde_json::json!({
"count": total,
"success": success,
"timing": {
"dns_lookup": stat_obj(&calc(|s| s.dns_lookup)),
"tcp_connect": stat_obj(&calc(|s| s.tcp_connect)),
"tls_handshake": stat_obj(&calc(|s| s.tls_handshake)),
"quic_connect": stat_obj(&calc(|s| s.quic_connect)),
"server_processing": stat_obj(&calc(|s| s.server_processing)),
"content_transfer": stat_obj(&calc(|s| s.content_transfer)),
"total": stat_obj(&calc(|s| s.total)),
},
});
if let Some(cs) = connect_stat {
obj["cold_connect"] = serde_json::json!({
"dns_lookup_us": dur_us(cs.dns_lookup),
"tcp_connect_us": dur_us(cs.tcp_connect),
"tls_handshake_us": dur_us(cs.tls_handshake),
"total_us": dur_us(cs.total),
});
}
obj
}
async fn handle_output(body: Option<Bytes>, output: Option<String>) {
let Some(output) = output else {
return;
};
let Some(body) = body else {
return;
};
if let Err(e) = fs::write(output, body).await {
println!("write output error: {e}");
}
}
fn parse_dur(name: &str, value: &str) -> std::time::Duration {
match value.parse::<humantime::Duration>() {
Ok(d) => d.into(),
Err(e) => {
eprintln!("httpstat: invalid {name} '{value}': {e}");
std::process::exit(1);
}
}
}
const DEFAULT_MAX_FILESIZE: u64 = 1024 * 1024 * 1024;
fn parse_size(name: &str, value: &str) -> u64 {
match value.trim().parse::<bytesize::ByteSize>() {
Ok(v) => v.as_u64(),
Err(e) => {
eprintln!("httpstat: invalid {name} '{value}': {e}");
std::process::exit(1);
}
}
}
fn resolve_max_filesize(arg: Option<&str>) -> Option<usize> {
match arg {
None => Some(DEFAULT_MAX_FILESIZE as usize),
Some(v) => {
let n = parse_size("max-filesize", v);
if n == 0 {
None
} else {
Some(n as usize)
}
}
}
}
fn max_time_error_stat(d: std::time::Duration) -> HttpStat {
HttpStat {
total: Some(d),
error: Some(format!(
"timeout: exceeded --max-time of {}",
format_duration(d)
)),
..Default::default()
}
}
async fn with_max_time<F>(fut: F, max_time: Option<std::time::Duration>) -> HttpStat
where
F: std::future::Future<Output = HttpStat>,
{
match max_time {
Some(d) => match tokio::time::timeout(d, fut).await {
Ok(stat) => stat,
Err(_) => max_time_error_stat(d),
},
None => fut.await,
}
}
fn is_retryable(stat: &HttpStat) -> bool {
if let Some(status) = stat.status {
return matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504);
}
if stat.error.is_some() {
return matches!(stat.exit_code(), 1 | 3 | 5);
}
false
}
fn backoff_delay(n: usize) -> std::time::Duration {
let secs = (1u64 << n.min(5)).min(30);
std::time::Duration::from_secs(secs)
}
async fn run_with_retry<F, Fut>(
make: F,
retries: usize,
retry_delay: Option<std::time::Duration>,
) -> HttpStat
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = HttpStat>,
{
let mut attempt = 0usize;
loop {
let stat = make().await;
if attempt >= retries || !is_retryable(&stat) {
return stat;
}
let delay = retry_delay.unwrap_or_else(|| backoff_delay(attempt));
let reason = stat
.status
.map(|s| format!("HTTP {}", s.as_u16()))
.or_else(|| stat.error.clone())
.unwrap_or_else(|| "request failed".to_string());
eprintln!(
"httpstat: attempt {}/{} failed ({reason}); retrying in {}",
attempt + 1,
retries + 1,
format_duration(delay)
);
tokio::time::sleep(delay).await;
attempt += 1;
}
}
fn parse_alt_authority(authority: &str) -> Option<(String, u16)> {
let (host, port_str) = authority.rsplit_once(':')?;
let port: u16 = port_str.trim().parse().ok()?;
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
Some((host.to_string(), port))
}
fn h3_endpoint(stat: &HttpStat) -> Option<(String, u16)> {
stat.alt_svc
.as_ref()?
.iter()
.find(|e| e.protocol == "h3")
.and_then(|e| parse_alt_authority(&e.authority))
}
fn fmt_alt_endpoint(host: &str, port: u16) -> String {
if host.is_empty() {
format!(":{port}")
} else if host.contains(':') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
fn apply_alt_endpoint(req: &mut HttpRequest, alt_host: &str, alt_port: u16) {
req.alpn_protocols = vec![ALPN_HTTP3.to_string()];
let origin_host = req.uri.host().unwrap_or("").to_string();
let origin_port = req.get_port();
let target_host = if alt_host.is_empty() {
origin_host.as_str()
} else {
alt_host
};
if target_host != origin_host || alt_port != origin_port {
req.connect_to = vec![format!(
"{}:{}",
fmt_alt_endpoint(&origin_host, origin_port),
fmt_alt_endpoint(target_host, alt_port)
)];
}
}
struct RunOpts {
follow_redirect: bool,
max_time: Option<std::time::Duration>,
retries: usize,
retry_delay: Option<std::time::Duration>,
alt_svc: bool,
}
async fn run_request(req: HttpRequest, opts: &RunOpts) -> HttpStat {
let forced_h3 = req.alpn_protocols.iter().any(|p| p == ALPN_HTTP3);
let stat = run_with_retry(
|| with_max_time(do_request(req.clone(), opts.follow_redirect), opts.max_time),
opts.retries,
opts.retry_delay,
)
.await;
if !opts.alt_svc || forced_h3 {
return stat;
}
let Some((host, port)) = h3_endpoint(&stat) else {
return stat;
};
let mut h3_req = req;
apply_alt_endpoint(&mut h3_req, &host, port);
let h3_stat = run_with_retry(
|| {
with_max_time(
do_request(h3_req.clone(), opts.follow_redirect),
opts.max_time,
)
},
opts.retries,
opts.retry_delay,
)
.await;
if h3_stat.error.is_none() {
eprintln!(
"alt-svc: upgraded to HTTP/3 via {}",
fmt_alt_endpoint(&host, port)
);
h3_stat
} else {
eprintln!(
"alt-svc: HTTP/3 upgrade to {} failed ({}); showing original result",
fmt_alt_endpoint(&host, port),
h3_stat.error.as_deref().unwrap_or("unknown")
);
stat
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut args = Args::parse();
let config = load_config();
apply_config(&mut args, &config);
let lang = match args.lang.as_deref() {
Some(v) => Lang::parse_arg(v),
None => Lang::detect(),
};
let Some(url) = args.url.or(args.url_arg) else {
println!("httpstat: try 'httpstat -h' or 'httpstat --help' for more information");
std::process::exit(1);
};
let mut req: HttpRequest = match url.as_str().try_into() {
Ok(req) => req,
Err(e) => {
eprintln!("httpstat: invalid URL: {e}");
std::process::exit(1);
}
};
if args.ipv4 {
req.ip_version = Some(4);
}
if args.ipv6 {
req.ip_version = Some(6);
}
req.skip_verify = args.skip_verify;
if let Some(bind_str) = args.bind {
match bind_str.parse::<std::net::IpAddr>() {
Ok(ip) => req.bind_addr = Some(ip),
Err(_) => {
eprintln!("httpstat: invalid --bind address '{bind_str}'");
std::process::exit(1);
}
}
}
if let Some(dns_servers) = args.dns_servers {
req.dns_servers = Some(dns_servers.split(',').map(|s| s.to_string()).collect());
}
if let Some(timeout_str) = args.timeout {
let timeout = parse_dur("timeout", &timeout_str);
req.dns_timeout = Some(timeout);
req.tcp_timeout = Some(timeout);
req.tls_timeout = Some(timeout);
req.request_timeout = Some(timeout);
req.quic_timeout = Some(timeout);
}
if let Some(ct_str) = args.connect_timeout {
let ct = parse_dur("connect-timeout", &ct_str);
req.dns_timeout = Some(ct);
req.tcp_timeout = Some(ct);
req.tls_timeout = Some(ct);
req.quic_timeout = Some(ct);
}
req.max_body_size = resolve_max_filesize(args.max_filesize.as_deref());
let max_time = args.max_time.as_deref().map(|v| parse_dur("max-time", v));
let retries = args.retry.unwrap_or(0);
let retry_delay = args
.retry_delay
.as_deref()
.map(|v| parse_dur("retry-delay", v));
let follow_redirect = args.follow_redirect;
let run_opts = RunOpts {
follow_redirect,
max_time,
retries,
retry_delay,
alt_svc: args.alt_svc,
};
if !args.headers.is_empty() {
let mut header_map = HeaderMap::new();
for header in args.headers {
if let Some((name, value)) = header.split_once(':') {
let name = name.trim();
let value = value.trim();
if let Ok(header_name) = name.parse::<HeaderName>() {
if let Ok(header_value) = value.parse::<HeaderValue>() {
header_map.insert(header_name, header_value);
}
}
}
}
req.headers = Some(header_map);
}
if args.compressed {
let value = HeaderValue::from_static("gzip, br, zstd");
if let Some(header_map) = req.headers.as_mut() {
header_map.insert(http::header::ACCEPT_ENCODING, value);
} else {
let mut header_map = HeaderMap::new();
header_map.insert(http::header::ACCEPT_ENCODING, value);
req.headers = Some(header_map);
}
}
if let Some(cookie) = args.cookie {
let cookie_value = if let Some(file_path) = cookie.strip_prefix('@') {
match fs::read_to_string(file_path).await {
Ok(content) => content.trim().to_string(),
Err(e) => {
eprintln!("httpstat: failed to read cookie file '{}': {e}", file_path);
std::process::exit(1);
}
}
} else {
cookie
};
if let Ok(value) = cookie_value.parse::<HeaderValue>() {
let header_map = req.headers.get_or_insert_with(HeaderMap::new);
header_map.insert(http::header::COOKIE, value);
}
}
req.method = args.method;
if let Some(data) = args.data {
if let Some(file_path) = data.strip_prefix('@') {
if file_path == "-" {
let mut buf = Vec::new();
if let Err(e) = std::io::Read::read_to_end(&mut std::io::stdin(), &mut buf) {
eprintln!("httpstat: failed to read stdin: {e}");
std::process::exit(1);
}
req.body = Some(Bytes::from(buf));
} else {
match fs::read(file_path).await {
Ok(content) => req.body = Some(Bytes::from(content)),
Err(e) => {
eprintln!("httpstat: failed to read file '{}': {e}", file_path);
std::process::exit(1);
}
}
}
} else {
req.body = Some(Bytes::from(data));
}
}
if !args.connect_to.is_empty() {
for entry in &args.connect_to {
if ConnectTo::parse(entry).is_none() {
eprintln!(
"httpstat: invalid --connect-to '{}': expected HOST1:PORT1:HOST2:PORT2",
entry
);
std::process::exit(1);
}
}
req.connect_to = args.connect_to;
}
let proxy = args.proxy.or_else(|| {
let scheme = req.uri.scheme_str().unwrap_or("http");
let from_env = if scheme == "https" {
std::env::var("HTTPS_PROXY")
.or_else(|_| std::env::var("https_proxy"))
.ok()
} else {
std::env::var("HTTP_PROXY")
.or_else(|_| std::env::var("http_proxy"))
.ok()
};
from_env.or_else(|| {
std::env::var("ALL_PROXY")
.or_else(|_| std::env::var("all_proxy"))
.ok()
})
});
req.proxy = proxy;
match (args.cert, args.key) {
(Some(cert_path), Some(key_path)) => {
match (std::fs::read(&cert_path), std::fs::read(&key_path)) {
(Ok(cert), Ok(key)) => {
req.client_cert = Some(cert);
req.client_key = Some(key);
}
(Err(e), _) => {
eprintln!("httpstat: failed to read cert file '{}': {e}", cert_path);
std::process::exit(1);
}
(_, Err(e)) => {
eprintln!("httpstat: failed to read key file '{}': {e}", key_path);
std::process::exit(1);
}
}
}
(Some(_), None) => {
eprintln!("httpstat: --cert requires --key");
std::process::exit(1);
}
(None, Some(_)) => {
eprintln!("httpstat: --key requires --cert");
std::process::exit(1);
}
(None, None) => {}
}
if args.http1 {
req.alpn_protocols = vec![ALPN_HTTP1.to_string()];
}
if args.http2 {
req.alpn_protocols = vec![ALPN_HTTP2.to_string()];
}
if args.http3 {
req.alpn_protocols = vec![ALPN_HTTP3.to_string()];
}
let output = args.output;
let count = args.count.unwrap_or(1).max(1);
let include_headers: Option<Vec<String>> = if args.include_header.is_empty() {
None
} else {
Some(
args.include_header
.iter()
.map(|h| h.to_lowercase())
.collect(),
)
};
let exclude_headers: Option<Vec<String>> = if args.exclude_header.is_empty() {
None
} else {
Some(
args.exclude_header
.iter()
.map(|h| h.to_lowercase())
.collect(),
)
};
let json_output = args.json;
let mut exit_code = 0i32;
if let Some(resolve) = args.resolve {
let ips = resolve.split(',').collect::<Vec<&str>>();
let mut futs = vec![];
for ip in ips {
let mut req = req.clone();
let Ok(ip) = ip.parse::<IpAddr>() else {
continue;
};
req.resolve = Some(ip);
futs.push(run_request(req, &run_opts));
}
let mut stats_list = futures::future::join_all(futs).await;
stats_list.sort_by(|item1, item2| {
let value1 = item1.error.is_some();
let value2 = item2.error.is_some();
value1.cmp(&value2)
});
if json_output {
let arr: Vec<_> = stats_list.iter().map(|s| s.to_json()).collect();
println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
for s in &stats_list {
let code = s.exit_code();
if code != 0 && exit_code == 0 {
exit_code = code;
}
}
} else {
for mut stat in stats_list {
stat.verbose = args.verbose;
stat.silent = args.silent;
stat.pretty = args.pretty;
stat.waterfall = args.waterfall;
stat.show_tcp_info = args.tcp_info;
stat.lang = lang;
stat.jq_filter.clone_from(&args.jq);
stat.include_headers.clone_from(&include_headers);
stat.exclude_headers.clone_from(&exclude_headers);
let body = stat.body.clone();
handle_output(body, output.clone()).await;
if output.is_some() {
stat.body = None;
}
println!("{stat}");
if exit_code == 0 {
exit_code = stat.exit_code();
}
}
}
} else if count > 1 && args.reuse {
let (connect_stat, conn) = connect(&req).await;
if let Some(mut conn) = conn {
let width = count.to_string().len();
let mut stats = Vec::with_capacity(count);
for i in 0..count {
let mut stat = with_max_time(conn.send(&req), max_time).await;
stat.addr.clone_from(&connect_stat.addr);
stat.alpn.clone_from(&connect_stat.alpn);
stat.silent = true;
stat.lang = lang;
if !json_output {
print!("[{:>width$}/{count}] {stat}", i + 1);
}
if exit_code == 0 {
exit_code = stat.exit_code();
}
stat.body = None;
stats.push(stat);
}
if json_output {
let json_val = benchmark_to_json(&stats, Some(&connect_stat));
println!(
"{}",
serde_json::to_string_pretty(&json_val).unwrap_or_default()
);
} else {
let summary = BenchmarkSummary { stats, lang };
println!("{summary}");
let mut parts = vec![];
if let Some(d) = connect_stat.dns_lookup {
parts.push(format!("DNS {}", format_duration(d)));
}
if let Some(d) = connect_stat.tcp_connect {
parts.push(format!("TCP {}", format_duration(d)));
}
if let Some(d) = connect_stat.tls_handshake {
parts.push(format!("TLS {}", format_duration(d)));
}
println!(
" {}: {} ({})",
lang.strings().cold_connect,
format_duration(connect_stat.total.unwrap_or_default()),
parts.join(" + ")
);
}
} else {
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&connect_stat.to_json()).unwrap_or_default()
);
} else {
println!("{connect_stat}");
}
exit_code = connect_stat.exit_code();
}
} else if count > 1 {
let width = count.to_string().len();
let session_store = http_stat::new_tls_session_store(count.max(8));
let mut stats = Vec::with_capacity(count);
for i in 0..count {
let mut req = req.clone();
req.tls_session_store = Some(Arc::clone(&session_store));
let mut stat = with_max_time(do_request(req, args.follow_redirect), max_time).await;
stat.silent = true;
stat.lang = lang;
if !json_output {
print!("[{:>width$}/{count}] {stat}", i + 1);
}
if exit_code == 0 {
exit_code = stat.exit_code();
}
stat.body = None;
stats.push(stat);
}
if json_output {
let json_val = benchmark_to_json(&stats, None);
println!(
"{}",
serde_json::to_string_pretty(&json_val).unwrap_or_default()
);
} else {
let summary = BenchmarkSummary { stats, lang };
println!("{summary}");
}
} else {
let mut stat = run_request(req, &run_opts).await;
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&stat.to_json()).unwrap_or_default()
);
} else {
stat.verbose = args.verbose;
stat.silent = args.silent;
stat.pretty = args.pretty;
stat.waterfall = args.waterfall;
stat.show_tcp_info = args.tcp_info;
stat.lang = lang;
stat.jq_filter = args.jq;
stat.include_headers = include_headers;
stat.exclude_headers = exclude_headers;
let body = stat.body.clone();
handle_output(body, output.clone()).await;
if output.is_some() {
stat.body = None;
}
println!("{stat}");
}
exit_code = stat.exit_code();
}
if exit_code != 0 {
std::process::exit(exit_code);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_map(v: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
v.as_object().unwrap().clone()
}
#[test]
fn collect_cookies_merges_set_cookie_and_existing() {
let mut headers = HeaderMap::new();
headers.append(
http::header::SET_COOKIE,
HeaderValue::from_static("a=1; Path=/; HttpOnly"),
);
headers.append(http::header::SET_COOKIE, HeaderValue::from_static("b=2"));
let stat = HttpStat {
headers: Some(headers),
..Default::default()
};
let merged = collect_cookies(&stat, "c=3");
let set: std::collections::HashSet<&str> = merged.split("; ").collect();
assert_eq!(set.len(), 3);
assert!(set.contains("a=1"));
assert!(set.contains("b=2"));
assert!(set.contains("c=3"));
}
#[test]
fn collect_cookies_response_overrides_existing() {
let mut headers = HeaderMap::new();
headers.append(
http::header::SET_COOKIE,
HeaderValue::from_static("session=new"),
);
let stat = HttpStat {
headers: Some(headers),
..Default::default()
};
assert_eq!(collect_cookies(&stat, "session=old"), "session=new");
}
#[test]
fn apply_config_fills_missing_values() {
let mut args = Args::parse_from(["httpstat", "http://example.com"]);
assert!(!args.verbose);
assert!(args.timeout.is_none());
let map = cfg_map(serde_json::json!({
"verbose": true,
"timeout": "5s",
"headers": ["X-From-Config: yes"],
}));
apply_config(&mut args, &map);
assert!(args.verbose);
assert_eq!(args.timeout.as_deref(), Some("5s"));
assert_eq!(args.headers, vec!["X-From-Config: yes".to_string()]);
}
#[test]
fn apply_config_does_not_override_cli_values() {
let mut args = Args::parse_from(["httpstat", "--timeout", "1s", "http://example.com"]);
let map = cfg_map(serde_json::json!({ "timeout": "99s" }));
apply_config(&mut args, &map);
assert_eq!(args.timeout.as_deref(), Some("1s"));
}
#[test]
fn apply_config_prepends_header_defaults() {
let mut args = Args::parse_from(["httpstat", "-H", "X-Cli: 1", "http://example.com"]);
let map = cfg_map(serde_json::json!({ "headers": ["X-Config: 0"] }));
apply_config(&mut args, &map);
assert_eq!(
args.headers,
vec!["X-Config: 0".to_string(), "X-Cli: 1".to_string()]
);
}
#[test]
fn redirect_303_forces_get_except_head() {
assert!(redirect_downgrades_to_get(StatusCode::SEE_OTHER, "POST"));
assert!(redirect_downgrades_to_get(StatusCode::SEE_OTHER, "GET"));
assert!(redirect_downgrades_to_get(StatusCode::SEE_OTHER, "PUT"));
assert!(!redirect_downgrades_to_get(StatusCode::SEE_OTHER, "HEAD"));
}
#[test]
fn redirect_301_302_downgrade_only_post() {
for s in [StatusCode::MOVED_PERMANENTLY, StatusCode::FOUND] {
assert!(redirect_downgrades_to_get(s, "POST"));
assert!(!redirect_downgrades_to_get(s, "GET"));
assert!(!redirect_downgrades_to_get(s, "PUT"));
}
}
#[test]
fn redirect_307_308_preserve_method() {
for s in [
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
] {
assert!(!redirect_downgrades_to_get(s, "POST"));
assert!(!redirect_downgrades_to_get(s, "GET"));
}
}
#[test]
fn resolve_redirect_forms() {
let base: Uri = "http://example.com/a/b".parse().unwrap();
assert_eq!(
resolve_redirect(&base, "https://other.com/x")
.unwrap()
.to_string(),
"https://other.com/x"
);
assert_eq!(
resolve_redirect(&base, "//cdn.example.com/y")
.unwrap()
.to_string(),
"http://cdn.example.com/y"
);
assert_eq!(
resolve_redirect(&base, "/x?q=1").unwrap().to_string(),
"http://example.com/x?q=1"
);
assert_eq!(
resolve_redirect(&base, "c").unwrap().to_string(),
"http://example.com/a/c"
);
assert!(resolve_redirect(&base, "").is_none());
}
#[test]
fn max_time_error_stat_maps_to_timeout_exit() {
let s = max_time_error_stat(std::time::Duration::from_secs(2));
assert!(!s.is_success());
assert_eq!(s.exit_code(), 5); assert!(s.error.as_deref().unwrap().contains("max-time"));
}
#[tokio::test]
async fn with_max_time_passes_through_fast_result() {
let fast = async {
HttpStat {
status: Some(StatusCode::OK),
..Default::default()
}
};
let s = with_max_time(fast, Some(std::time::Duration::from_secs(10))).await;
assert_eq!(s.exit_code(), 0);
}
#[tokio::test]
async fn with_max_time_cancels_slow_result() {
let slow = async {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
HttpStat {
status: Some(StatusCode::OK),
..Default::default()
}
};
let s = with_max_time(slow, Some(std::time::Duration::from_millis(10))).await;
assert_eq!(s.exit_code(), 5); }
#[tokio::test]
async fn with_max_time_none_is_unbounded() {
let fut = async {
HttpStat {
status: Some(StatusCode::OK),
..Default::default()
}
};
assert_eq!(with_max_time(fut, None).await.exit_code(), 0);
}
#[test]
fn is_retryable_status_codes() {
for code in [408u16, 429, 500, 502, 503, 504] {
let s = HttpStat {
status: Some(StatusCode::from_u16(code).unwrap()),
..Default::default()
};
assert!(is_retryable(&s), "expected {code} retryable");
}
for code in [200u16, 404, 501, 505] {
let s = HttpStat {
status: Some(StatusCode::from_u16(code).unwrap()),
..Default::default()
};
assert!(!is_retryable(&s), "expected {code} non-retryable");
}
}
#[test]
fn is_retryable_connection_errors() {
let ms = std::time::Duration::from_millis(1);
let to = HttpStat {
error: Some("operation timeout".into()),
..Default::default()
};
assert!(is_retryable(&to));
let tcp = HttpStat {
error: Some("connection refused".into()),
dns_lookup: Some(ms),
..Default::default()
};
assert!(is_retryable(&tcp));
let reset = HttpStat {
error: Some("connection reset by peer".into()),
dns_lookup: Some(ms),
tcp_connect: Some(ms),
..Default::default()
};
assert!(is_retryable(&reset));
let dns = HttpStat {
error: Some("no such host".into()),
dns_attempted: true,
..Default::default()
};
assert!(!is_retryable(&dns));
let tls = HttpStat {
error: Some("rustls: bad certificate".into()),
dns_lookup: Some(ms),
tcp_connect: Some(ms),
..Default::default()
};
assert!(!is_retryable(&tls));
assert!(!is_retryable(&HttpStat::default()));
}
#[test]
fn backoff_is_exponential_capped() {
assert_eq!(backoff_delay(0), std::time::Duration::from_secs(1));
assert_eq!(backoff_delay(1), std::time::Duration::from_secs(2));
assert_eq!(backoff_delay(2), std::time::Duration::from_secs(4));
assert_eq!(backoff_delay(4), std::time::Duration::from_secs(16));
assert_eq!(backoff_delay(5), std::time::Duration::from_secs(30)); assert_eq!(backoff_delay(20), std::time::Duration::from_secs(30)); }
#[tokio::test]
async fn run_with_retry_retries_then_succeeds() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let make = || {
let n = calls.fetch_add(1, Ordering::SeqCst);
async move {
let status = if n < 2 {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::OK
};
HttpStat {
status: Some(status),
..Default::default()
}
}
};
let stat = run_with_retry(make, 5, Some(std::time::Duration::from_millis(1))).await;
assert_eq!(stat.exit_code(), 0);
assert_eq!(calls.load(Ordering::SeqCst), 3); }
#[tokio::test]
async fn run_with_retry_gives_up_after_n() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let make = || {
calls.fetch_add(1, Ordering::SeqCst);
async {
HttpStat {
status: Some(StatusCode::BAD_GATEWAY),
..Default::default()
}
}
};
let stat = run_with_retry(make, 2, Some(std::time::Duration::from_millis(1))).await;
assert_eq!(stat.exit_code(), 7); assert_eq!(calls.load(Ordering::SeqCst), 3); }
#[tokio::test]
async fn run_with_retry_does_not_retry_non_transient() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = AtomicUsize::new(0);
let make = || {
calls.fetch_add(1, Ordering::SeqCst);
async {
HttpStat {
status: Some(StatusCode::NOT_FOUND),
..Default::default()
}
}
};
let stat = run_with_retry(make, 5, Some(std::time::Duration::from_millis(1))).await;
assert_eq!(stat.exit_code(), 6); assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn parse_alt_authority_forms() {
assert_eq!(parse_alt_authority(":443"), Some((String::new(), 443)));
assert_eq!(
parse_alt_authority("alt.example.com:8443"),
Some(("alt.example.com".to_string(), 8443))
);
assert_eq!(
parse_alt_authority("[::1]:443"),
Some(("::1".to_string(), 443))
);
assert!(parse_alt_authority("noport").is_none());
assert!(parse_alt_authority(":notnum").is_none());
}
#[test]
fn h3_endpoint_picks_h3() {
let stat = HttpStat {
alt_svc: Some(vec![
http_stat::AltSvc {
protocol: "h2".into(),
authority: ":443".into(),
max_age: None,
},
http_stat::AltSvc {
protocol: "h3".into(),
authority: ":8443".into(),
max_age: Some(86400),
},
]),
..Default::default()
};
assert_eq!(h3_endpoint(&stat), Some((String::new(), 8443)));
let no_h3 = HttpStat {
alt_svc: Some(vec![http_stat::AltSvc {
protocol: "h2".into(),
authority: ":443".into(),
max_age: None,
}]),
..Default::default()
};
assert!(h3_endpoint(&no_h3).is_none());
assert!(h3_endpoint(&HttpStat::default()).is_none());
}
#[test]
fn fmt_alt_endpoint_forms() {
assert_eq!(fmt_alt_endpoint("", 443), ":443");
assert_eq!(fmt_alt_endpoint("h", 8443), "h:8443");
assert_eq!(fmt_alt_endpoint("::1", 443), "[::1]:443");
}
#[test]
fn apply_alt_endpoint_same_origin_needs_no_connect_to() {
let mut req = HttpRequest::try_from("https://example.com").unwrap();
apply_alt_endpoint(&mut req, "", 443); assert_eq!(req.alpn_protocols, vec![ALPN_HTTP3.to_string()]);
assert!(req.connect_to.is_empty());
}
#[test]
fn apply_alt_endpoint_different_endpoint_uses_connect_to() {
let mut req = HttpRequest::try_from("https://example.com").unwrap();
apply_alt_endpoint(&mut req, "", 8443);
assert_eq!(
req.connect_to,
vec!["example.com:443:example.com:8443".to_string()]
);
let mut req2 = HttpRequest::try_from("https://example.com").unwrap();
apply_alt_endpoint(&mut req2, "alt.example.com", 443);
assert_eq!(
req2.connect_to,
vec!["example.com:443:alt.example.com:443".to_string()]
);
}
#[test]
fn parse_size_accepts_common_forms() {
assert_eq!(parse_size("max-filesize", "100MB"), 100_000_000);
assert_eq!(parse_size("max-filesize", "1MiB"), 1024 * 1024);
assert_eq!(parse_size("max-filesize", "52428800"), 52_428_800);
assert_eq!(parse_size("max-filesize", "0"), 0);
}
#[test]
fn resolve_max_filesize_default_and_unlimited() {
assert_eq!(
resolve_max_filesize(None),
Some(DEFAULT_MAX_FILESIZE as usize)
);
assert_eq!(resolve_max_filesize(Some("0")), None);
assert_eq!(resolve_max_filesize(Some("10MB")), Some(10_000_000));
}
}