From 90a9b6204d0280c5600aa62934a2425c6e1ead14 Mon Sep 17 00:00:00 2001 From: Robin B Date: Sat, 14 Feb 2026 12:53:31 +0100 Subject: [PATCH 1/7] fix: retry rate-limited samples and report attempt stats --- src/measurements.rs | 136 +++++++--- src/speedtest.rs | 632 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 690 insertions(+), 78 deletions(-) diff --git a/src/measurements.rs b/src/measurements.rs index b411785..e1a5cd1 100644 --- a/src/measurements.rs +++ b/src/measurements.rs @@ -10,12 +10,16 @@ use std::{fmt::Display, io}; struct StatMeasurement { test_type: TestType, payload_size: usize, - min: f64, - q1: f64, - median: f64, - q3: f64, - max: f64, - avg: f64, + min: Option, + q1: Option, + median: Option, + q3: Option, + max: Option, + avg: Option, + attempts: u32, + successes: u32, + skipped: u32, + target_successes: u32, } #[derive(Serialize)] @@ -33,6 +37,16 @@ pub struct LatencyMeasurement { pub latency_measurements: Vec, } +#[derive(Clone, Debug, Serialize)] +pub struct PayloadAttemptStats { + pub test_type: TestType, + pub payload_size: usize, + pub attempts: u32, + pub successes: u32, + pub skipped: u32, + pub target_successes: u32, +} + impl Display for Measurement { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( @@ -47,6 +61,7 @@ impl Display for Measurement { pub(crate) fn log_measurements( measurements: &[Measurement], + payload_attempt_stats: &[PayloadAttemptStats], latency_measurement: Option<&LatencyMeasurement>, payload_sizes: Vec, verbose: bool, @@ -55,23 +70,27 @@ pub(crate) fn log_measurements( ) { if output_format == OutputFormat::StdOut { println!("\nSummary Statistics"); - println!("Type Payload | min/max/avg in mbit/s"); + println!("Type Payload | min/max/avg in mbit/s | attempts/success/skipped"); } let mut stat_measurements: Vec = Vec::new(); - measurements + let mut test_types = measurements .iter() .map(|m| m.test_type) - .collect::>() + .collect::>(); + payload_attempt_stats .iter() - .for_each(|t| { - stat_measurements.extend(log_measurements_by_test_type( - measurements, - payload_sizes.clone(), - verbose, - output_format, - *t, - )) - }); + .for_each(|stats| _ = test_types.insert(stats.test_type)); + + test_types.iter().for_each(|test_type| { + stat_measurements.extend(log_measurements_by_test_type( + measurements, + payload_attempt_stats, + payload_sizes.clone(), + verbose, + output_format, + *test_type, + )) + }); match output_format { OutputFormat::Csv => { let mut wtr = csv::Writer::from_writer(io::stdout()); @@ -122,6 +141,7 @@ fn compose_output_json( fn log_measurements_by_test_type( measurements: &[Measurement], + payload_attempt_stats: &[PayloadAttemptStats], payload_sizes: Vec, verbose: bool, output_format: OutputFormat, @@ -135,33 +155,73 @@ fn log_measurements_by_test_type( .filter(|m| m.payload_size == payload_size) .map(|m| m.mbit) .collect(); + let payload_attempt_stat = payload_attempt_stats + .iter() + .find(|stats| stats.test_type == test_type && stats.payload_size == payload_size); + + if type_measurements.is_empty() && payload_attempt_stat.is_none() { + continue; + } + + let attempts = payload_attempt_stat.map_or(0, |stats| stats.attempts); + let successes = payload_attempt_stat.map_or(0, |stats| stats.successes); + let skipped = payload_attempt_stat.map_or(0, |stats| stats.skipped); + let target_successes = payload_attempt_stat.map_or(0, |stats| stats.target_successes); + + let formatted_payload = format_bytes(payload_size); + let fmt_test_type = format!("{test_type:?}"); - // check if there are any measurements for the current payload_size - // skip stats calculation if there are no measurements if !type_measurements.is_empty() { let (min, q1, median, q3, max, avg) = calc_stats(type_measurements).unwrap(); - let formatted_payload = format_bytes(payload_size); - let fmt_test_type = format!("{test_type:?}"); stat_measurements.push(StatMeasurement { test_type, payload_size, - min, - q1, - median, - q3, - max, - avg, + min: Some(min), + q1: Some(q1), + median: Some(median), + q3: Some(q3), + max: Some(max), + avg: Some(avg), + attempts, + successes, + skipped, + target_successes, }); if output_format == OutputFormat::StdOut { println!( - "{fmt_test_type:<9} {formatted_payload:<7}| min {min:<7.2} max {max:<7.2} avg {avg:<7.2}" - ); + "{fmt_test_type:<9} {formatted_payload:<7}| min {min:<7.2} max {max:<7.2} avg {avg:<7.2} | {attempts:>3}/{successes:>3}/{skipped:>3}" + ); + if successes < target_successes { + println!( + " insufficient samples: collected {successes}/{target_successes} successful runs" + ); + } if verbose { let plot = boxplot::render_plot(min, q1, median, q3, max); println!("{plot}\n"); } } + } else { + stat_measurements.push(StatMeasurement { + test_type, + payload_size, + min: None, + q1: None, + median: None, + q3: None, + max: None, + avg: None, + attempts, + successes, + skipped, + target_successes, + }); + if output_format == OutputFormat::StdOut { + println!( + "{fmt_test_type:<9} {formatted_payload:<7}| min N/A max N/A avg N/A | {attempts:>3}/{successes:>3}/{skipped:>3} (insufficient samples)" + ); + } } } @@ -330,12 +390,16 @@ mod tests { let stat_measurements = vec![StatMeasurement { test_type: TestType::Download, payload_size: 100_000, - min: 1.0, - q1: 1.5, - median: 2.0, - q3: 2.5, - max: 3.0, - avg: 2.0, + min: Some(1.0), + q1: Some(1.5), + median: Some(2.0), + q3: Some(2.5), + max: Some(3.0), + avg: Some(2.0), + attempts: 3, + successes: 3, + skipped: 0, + target_successes: 3, }]; let latency = LatencyMeasurement { avg_latency_ms: 10.0, diff --git a/src/speedtest.rs b/src/speedtest.rs index a53286c..dbba6f8 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -2,16 +2,18 @@ use crate::measurements::format_bytes; use crate::measurements::log_measurements; use crate::measurements::LatencyMeasurement; use crate::measurements::Measurement; +use crate::measurements::PayloadAttemptStats; use crate::progress::print_progress; use crate::OutputFormat; use crate::SpeedTestCLIOptions; use log; use regex::Regex; -use reqwest::{blocking::Client, StatusCode}; +use reqwest::{blocking::Client, header::RETRY_AFTER, StatusCode}; use serde::Serialize; use std::{ fmt::Display, sync::atomic::{AtomicBool, Ordering}, + thread, time::{Duration, Instant}, }; @@ -19,6 +21,10 @@ const BASE_URL: &str = "https://speed.cloudflare.com"; const DOWNLOAD_URL: &str = "__down?bytes="; const UPLOAD_URL: &str = "__up"; static WARNED_NEGATIVE_LATENCY: AtomicBool = AtomicBool::new(false); +const TIME_THRESHOLD: Duration = Duration::from_secs(5); +const MAX_ATTEMPT_FACTOR: u32 = 4; +const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(250); +const RETRY_MAX_BACKOFF: Duration = Duration::from_secs(3); #[derive(Clone, Copy, Debug, Hash, Serialize, Eq, PartialEq)] pub enum TestType { @@ -116,33 +122,37 @@ pub fn speed_test(client: Client, options: SpeedTestCLIOptions) -> Vec f64 { req_latency } -const TIME_THRESHOLD: Duration = Duration::from_secs(5); +#[derive(Debug)] +enum SampleOutcome { + Success { + mbits: f64, + duration: Duration, + status_code: StatusCode, + }, + RetryableFailure { + duration: Duration, + status_code: Option, + retry_after: Option, + reason: String, + }, + Failed { + duration: Duration, + status_code: Option, + reason: String, + }, +} pub fn run_tests( client: &Client, @@ -229,7 +257,7 @@ pub fn run_tests( ) -> Vec { let mut measurements: Vec = Vec::new(); for payload_size in payload_sizes { - log::debug!("running tests for payload_size {payload_size}"); + log::debug!("running compatibility test loop for payload_size {payload_size}"); let start = Instant::now(); for i in 0..nr_tests { if output_format == OutputFormat::StdOut { @@ -240,11 +268,13 @@ pub fn run_tests( ); } let mbit = test_fn(client, payload_size, output_format); - measurements.push(Measurement { - test_type, - payload_size, - mbit, - }); + if mbit.is_finite() { + measurements.push(Measurement { + test_type, + payload_size, + mbit, + }); + } } if output_format == OutputFormat::StdOut { print_progress( @@ -252,60 +282,361 @@ pub fn run_tests( nr_tests, nr_tests, ); - println!() + println!(); } - let duration = start.elapsed(); + if !disable_dynamic_max_payload_size && start.elapsed() > TIME_THRESHOLD { + log::info!("Exceeded threshold"); + break; + } + } + measurements +} + +pub fn run_tests_with_retries( + client: &Client, + test_type: TestType, + payload_sizes: Vec, + nr_tests: u32, + output_format: OutputFormat, + disable_dynamic_max_payload_size: bool, +) -> (Vec, Vec) { + run_tests_with_sleep( + client, + test_type, + payload_sizes, + nr_tests, + output_format, + disable_dynamic_max_payload_size, + BASE_URL, + thread::sleep, + ) +} - // only check TIME_THRESHOLD if dynamic max payload sizing is not disabled +fn run_tests_with_sleep( + client: &Client, + test_type: TestType, + payload_sizes: Vec, + nr_tests: u32, + output_format: OutputFormat, + disable_dynamic_max_payload_size: bool, + base_url: &str, + sleep_fn: S, +) -> (Vec, Vec) +where + S: Fn(Duration), +{ + let mut measurements: Vec = Vec::new(); + let mut payload_attempt_stats = Vec::new(); + + for payload_size in payload_sizes { + let label = format!("{:?} {:<5}", test_type, format_bytes(payload_size)); + log::debug!("running tests for payload_size {payload_size}"); + let start = Instant::now(); + + let mut attempts = 0; + let mut successes = 0; + let mut skipped = 0; + let max_attempts = nr_tests.saturating_mul(MAX_ATTEMPT_FACTOR).max(nr_tests); + + while successes < nr_tests && attempts < max_attempts { + if output_format == OutputFormat::StdOut { + print_progress(&label, successes, nr_tests); + } + + attempts += 1; + let sample_outcome = match test_type { + TestType::Download => { + test_download_with_base_url(client, payload_size, output_format, base_url) + } + TestType::Upload => { + test_upload_with_base_url(client, payload_size, output_format, base_url) + } + }; + + match sample_outcome { + SampleOutcome::Success { + mbits, + duration, + status_code, + } => { + log::debug!( + "{test_type:?} {} success: status={} duration={}ms throughput={mbits:.2} mbit/s", + format_bytes(payload_size), + status_code, + duration.as_millis(), + ); + successes += 1; + measurements.push(Measurement { + test_type, + payload_size, + mbit: mbits, + }); + } + SampleOutcome::RetryableFailure { + duration, + status_code, + retry_after, + reason, + } => { + skipped += 1; + if attempts < max_attempts { + let delay = compute_retry_delay(attempts, retry_after); + let status = status_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "transport error".to_string()); + log::warn!( + "{test_type:?} {} failed ({status}) after {}ms: {reason}. retrying in {}ms ({attempts}/{max_attempts})", + format_bytes(payload_size), + duration.as_millis(), + delay.as_millis(), + ); + if output_format == OutputFormat::StdOut { + print_retry_notice(delay, attempts, max_attempts); + } + sleep_fn(delay); + } + } + SampleOutcome::Failed { + duration, + status_code, + reason, + } => { + skipped += 1; + let status = status_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "transport error".to_string()); + log::warn!( + "{test_type:?} {} failed ({status}) after {}ms: {reason}. aborting this payload", + format_bytes(payload_size), + duration.as_millis(), + ); + break; + } + } + } + + if output_format == OutputFormat::StdOut { + print_progress(&label, successes, nr_tests); + println!(); + } + + payload_attempt_stats.push(PayloadAttemptStats { + test_type, + payload_size, + attempts, + successes, + skipped, + target_successes: nr_tests, + }); + + if successes < nr_tests { + log::warn!( + "{test_type:?} {} collected {successes}/{nr_tests} successful samples after {attempts} attempts", + format_bytes(payload_size), + ); + } + + let duration = start.elapsed(); if !disable_dynamic_max_payload_size && duration > TIME_THRESHOLD { log::info!("Exceeded threshold"); break; } } - measurements + + (measurements, payload_attempt_stats) } pub fn test_upload(client: &Client, payload_size_bytes: usize, output_format: OutputFormat) -> f64 { - let url = &format!("{BASE_URL}/{UPLOAD_URL}"); + match test_upload_with_base_url(client, payload_size_bytes, output_format, BASE_URL) { + SampleOutcome::Success { mbits, .. } => mbits, + SampleOutcome::RetryableFailure { .. } | SampleOutcome::Failed { .. } => f64::NAN, + } +} + +pub fn test_download( + client: &Client, + payload_size_bytes: usize, + output_format: OutputFormat, +) -> f64 { + match test_download_with_base_url(client, payload_size_bytes, output_format, BASE_URL) { + SampleOutcome::Success { mbits, .. } => mbits, + SampleOutcome::RetryableFailure { .. } | SampleOutcome::Failed { .. } => f64::NAN, + } +} + +fn test_upload_with_base_url( + client: &Client, + payload_size_bytes: usize, + output_format: OutputFormat, + base_url: &str, +) -> SampleOutcome { + let url = format!("{base_url}/{UPLOAD_URL}"); let payload: Vec = vec![1; payload_size_bytes]; - let req_builder = client.post(url).body(payload); - let (mut response, status_code, mbits, duration) = { - let start = Instant::now(); - let response = req_builder.send().expect("failed to get response"); - let status_code = response.status(); - let duration = start.elapsed(); - let mbits = (payload_size_bytes as f64 * 8.0 / 1_000_000.0) / duration.as_secs_f64(); - (response, status_code, mbits, duration) + let req_builder = client.post(&url).body(payload); + + let start = Instant::now(); + let mut response = match req_builder.send() { + Ok(response) => response, + Err(error) => { + let duration = start.elapsed(); + if output_format == OutputFormat::StdOut { + print_transport_failure(duration, payload_size_bytes, &error); + } + if error.is_timeout() { + return SampleOutcome::RetryableFailure { + duration, + status_code: None, + retry_after: None, + reason: error.to_string(), + }; + } + return SampleOutcome::Failed { + duration, + status_code: None, + reason: error.to_string(), + }; + } }; + + let status_code = response.status(); // Drain response after timing so we don't skew upload measurement. let _ = std::io::copy(&mut response, &mut std::io::sink()); + let duration = start.elapsed(); + if !status_code.is_success() { + if output_format == OutputFormat::StdOut { + print_skipped_sample(duration, status_code, payload_size_bytes); + } + let retry_after = parse_retry_after(response.headers().get(RETRY_AFTER)); + return if is_retryable_status(status_code) { + SampleOutcome::RetryableFailure { + duration, + status_code: Some(status_code), + retry_after, + reason: "retryable HTTP status".to_string(), + } + } else { + SampleOutcome::Failed { + duration, + status_code: Some(status_code), + reason: "non-retryable HTTP status".to_string(), + } + }; + } + + let mbits = (payload_size_bytes as f64 * 8.0 / 1_000_000.0) / duration.as_secs_f64(); if output_format == OutputFormat::StdOut { print_current_speed(mbits, duration, status_code, payload_size_bytes); } - mbits + SampleOutcome::Success { + mbits, + duration, + status_code, + } } -pub fn test_download( +fn test_download_with_base_url( client: &Client, payload_size_bytes: usize, output_format: OutputFormat, -) -> f64 { - let url = &format!("{BASE_URL}/{DOWNLOAD_URL}{payload_size_bytes}"); - let req_builder = client.get(url); - let (status_code, mbits, duration) = { - let start = Instant::now(); - let mut response = req_builder.send().expect("failed to get response"); - let status_code = response.status(); - // Stream the body to avoid buffering the full payload in memory. - let _ = std::io::copy(&mut response, &mut std::io::sink()); - let duration = start.elapsed(); - let mbits = (payload_size_bytes as f64 * 8.0 / 1_000_000.0) / duration.as_secs_f64(); - (status_code, mbits, duration) + base_url: &str, +) -> SampleOutcome { + let url = format!("{base_url}/{DOWNLOAD_URL}{payload_size_bytes}"); + let req_builder = client.get(&url); + + let start = Instant::now(); + let mut response = match req_builder.send() { + Ok(response) => response, + Err(error) => { + let duration = start.elapsed(); + if output_format == OutputFormat::StdOut { + print_transport_failure(duration, payload_size_bytes, &error); + } + if error.is_timeout() { + return SampleOutcome::RetryableFailure { + duration, + status_code: None, + retry_after: None, + reason: error.to_string(), + }; + } + return SampleOutcome::Failed { + duration, + status_code: None, + reason: error.to_string(), + }; + } }; + + let status_code = response.status(); + // Stream the body to avoid buffering the full payload in memory. + let _ = std::io::copy(&mut response, &mut std::io::sink()); + let duration = start.elapsed(); + if !status_code.is_success() { + if output_format == OutputFormat::StdOut { + print_skipped_sample(duration, status_code, payload_size_bytes); + } + let retry_after = parse_retry_after(response.headers().get(RETRY_AFTER)); + return if is_retryable_status(status_code) { + SampleOutcome::RetryableFailure { + duration, + status_code: Some(status_code), + retry_after, + reason: "retryable HTTP status".to_string(), + } + } else { + SampleOutcome::Failed { + duration, + status_code: Some(status_code), + reason: "non-retryable HTTP status".to_string(), + } + }; + } + + let mbits = (payload_size_bytes as f64 * 8.0 / 1_000_000.0) / duration.as_secs_f64(); if output_format == OutputFormat::StdOut { print_current_speed(mbits, duration, status_code, payload_size_bytes); } - mbits + SampleOutcome::Success { + mbits, + duration, + status_code, + } +} + +fn is_retryable_status(status_code: StatusCode) -> bool { + matches!( + status_code.as_u16(), + 408 | 425 | 429 | 500 | 502 | 503 | 504 + ) +} + +fn parse_retry_after(retry_after: Option<&reqwest::header::HeaderValue>) -> Option { + retry_after + .and_then(|header| header.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .map(Duration::from_secs) +} + +fn compute_retry_delay(attempt: u32, retry_after: Option) -> Duration { + if let Some(delay) = retry_after { + return delay; + } + + let exponent = attempt.saturating_sub(1).min(4); + let base_delay_ms = RETRY_BASE_BACKOFF.as_millis() as u64; + let capped_delay_ms = RETRY_MAX_BACKOFF.as_millis() as u64; + let delay_ms = base_delay_ms + .saturating_mul(1_u64 << exponent) + .min(capped_delay_ms); + + let jitter = delay_ms / 5; + let jittered_delay = if attempt.is_multiple_of(2) { + delay_ms.saturating_add(jitter).min(capped_delay_ms) + } else { + delay_ms.saturating_sub(jitter) + }; + + Duration::from_millis(jittered_delay) } fn print_current_speed( @@ -323,6 +654,35 @@ fn print_current_speed( ); } +fn print_skipped_sample(duration: Duration, status_code: StatusCode, payload_size_bytes: usize) { + print!( + " {:>6} mbit/s | {:>5} in {:>4}ms -> status: {} (skipped) ", + "N/A", + format_bytes(payload_size_bytes), + duration.as_millis(), + status_code + ); +} + +fn print_retry_notice(delay: Duration, attempt: u32, max_attempts: u32) { + print!( + " retrying in {}ms ({}/{}) ", + delay.as_millis(), + attempt, + max_attempts + ); +} + +fn print_transport_failure(duration: Duration, payload_size_bytes: usize, error: &reqwest::Error) { + print!( + " {:>6} mbit/s | {:>5} in {:>4}ms -> error: {} (skipped) ", + "N/A", + format_bytes(payload_size_bytes), + duration.as_millis(), + error + ); +} + pub fn fetch_metadata(client: &Client) -> Result { const TRACE_URL: &str = "https://speed.cloudflare.com/cdn-cgi/trace"; @@ -372,6 +732,79 @@ fn parse_trace_response(body: &str) -> std::collections::HashMap #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::sync::Arc; + use std::thread; + use std::time::Duration; + + #[derive(Clone)] + struct MockHttpResponse { + status_code: u16, + reason: &'static str, + headers: Vec<(&'static str, &'static str)>, + body: &'static str, + } + + fn spawn_mock_http_server( + responses: Vec, + ) -> (String, Arc, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind mock HTTP server"); + let addr = listener + .local_addr() + .expect("failed to read mock HTTP server addr"); + listener + .set_nonblocking(true) + .expect("failed to set nonblocking mode"); + let served = Arc::new(AtomicUsize::new(0)); + let served_counter = Arc::clone(&served); + let handle = thread::spawn(move || { + let mut idx = 0usize; + let mut idle_since = Instant::now(); + while idx < responses.len() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut buf = [0_u8; 1024]; + let _ = stream.read(&mut buf); + + let response = &responses[idx]; + let mut response_head = format!( + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n", + response.status_code, + response.reason, + response.body.len(), + ); + for (header, value) in &response.headers { + response_head.push_str(&format!("{header}: {value}\r\n")); + } + response_head.push_str("\r\n"); + + stream + .write_all(response_head.as_bytes()) + .expect("failed to write mock response head"); + if !response.body.is_empty() { + stream + .write_all(response.body.as_bytes()) + .expect("failed to write mock response body"); + } + idx += 1; + served_counter.store(idx, AtomicOrdering::SeqCst); + idle_since = Instant::now(); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if idle_since.elapsed() > Duration::from_secs(2) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + + (format!("http://{}", addr), served, handle) + } #[test] fn test_payload_size_from_valid_inputs() { @@ -564,6 +997,121 @@ mod tests { assert_eq!(parsed.get("key2"), Some(&"value=with=equals".to_string())); } + #[test] + fn test_run_tests_retries_429_and_records_success() { + let responses = vec![ + MockHttpResponse { + status_code: 429, + reason: "Too Many Requests", + headers: vec![("Retry-After", "0")], + body: "", + }, + MockHttpResponse { + status_code: 200, + reason: "OK", + headers: vec![], + body: "ok", + }, + ]; + let (base_url, served_counter, handle) = spawn_mock_http_server(responses); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("failed to build test client"); + + let (measurements, payload_stats) = run_tests_with_sleep( + &client, + TestType::Download, + vec![100_000], + 1, + OutputFormat::None, + true, + &base_url, + |_| {}, + ); + + assert_eq!(measurements.len(), 1); + assert_eq!(payload_stats.len(), 1); + assert_eq!(payload_stats[0].attempts, 2); + assert_eq!(payload_stats[0].successes, 1); + assert_eq!(payload_stats[0].skipped, 1); + + handle.join().expect("mock server thread panicked"); + assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 2); + } + + #[test] + fn test_run_tests_stops_after_max_attempts_on_retryable_failures() { + let responses = (0..8) + .map(|_| MockHttpResponse { + status_code: 429, + reason: "Too Many Requests", + headers: vec![("Retry-After", "0")], + body: "", + }) + .collect::>(); + let (base_url, served_counter, handle) = spawn_mock_http_server(responses); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("failed to build test client"); + + let (measurements, payload_stats) = run_tests_with_sleep( + &client, + TestType::Download, + vec![100_000], + 2, + OutputFormat::None, + true, + &base_url, + |_| {}, + ); + + assert!(measurements.is_empty()); + assert_eq!(payload_stats.len(), 1); + assert_eq!(payload_stats[0].attempts, 8); + assert_eq!(payload_stats[0].successes, 0); + assert_eq!(payload_stats[0].skipped, 8); + + handle.join().expect("mock server thread panicked"); + assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 8); + } + + #[test] + fn test_run_tests_does_not_retry_non_retryable_4xx() { + let responses = vec![MockHttpResponse { + status_code: 404, + reason: "Not Found", + headers: vec![], + body: "", + }]; + let (base_url, served_counter, handle) = spawn_mock_http_server(responses); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("failed to build test client"); + + let (measurements, payload_stats) = run_tests_with_sleep( + &client, + TestType::Download, + vec![100_000], + 2, + OutputFormat::None, + true, + &base_url, + |_| {}, + ); + + assert!(measurements.is_empty()); + assert_eq!(payload_stats.len(), 1); + assert_eq!(payload_stats[0].attempts, 1); + assert_eq!(payload_stats[0].successes, 0); + assert_eq!(payload_stats[0].skipped, 1); + + handle.join().expect("mock server thread panicked"); + assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 1); + } + #[test] fn test_fetch_metadata_integration() { // This test verifies that Cloudflare's trace endpoint returns the expected metadata fields. From 8cb2dea1a97acfce25838da33f37d7bddfbbc5a5 Mon Sep 17 00:00:00 2001 From: Robin B Date: Sat, 14 Feb 2026 13:00:22 +0100 Subject: [PATCH 2/7] fix: keep retry output concise --- src/speedtest.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/speedtest.rs b/src/speedtest.rs index dbba6f8..846a809 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -525,7 +525,7 @@ fn test_upload_with_base_url( let mbits = (payload_size_bytes as f64 * 8.0 / 1_000_000.0) / duration.as_secs_f64(); if output_format == OutputFormat::StdOut { - print_current_speed(mbits, duration, status_code, payload_size_bytes); + print_current_speed(mbits, duration, payload_size_bytes); } SampleOutcome::Success { mbits, @@ -594,7 +594,7 @@ fn test_download_with_base_url( let mbits = (payload_size_bytes as f64 * 8.0 / 1_000_000.0) / duration.as_secs_f64(); if output_format == OutputFormat::StdOut { - print_current_speed(mbits, duration, status_code, payload_size_bytes); + print_current_speed(mbits, duration, payload_size_bytes); } SampleOutcome::Success { mbits, @@ -639,24 +639,18 @@ fn compute_retry_delay(attempt: u32, retry_after: Option) -> Duration Duration::from_millis(jittered_delay) } -fn print_current_speed( - mbits: f64, - duration: Duration, - status_code: StatusCode, - payload_size_bytes: usize, -) { +fn print_current_speed(mbits: f64, duration: Duration, payload_size_bytes: usize) { print!( - " {:>6.2} mbit/s | {:>5} in {:>4}ms -> status: {} ", + " {:>6.2} mbit/s | {:>5} in {:>4}ms ", mbits, format_bytes(payload_size_bytes), duration.as_millis(), - status_code ); } fn print_skipped_sample(duration: Duration, status_code: StatusCode, payload_size_bytes: usize) { print!( - " {:>6} mbit/s | {:>5} in {:>4}ms -> status: {} (skipped) ", + " {:>6} mbit/s | {:>5} in {:>4}ms -> status: {} ", "N/A", format_bytes(payload_size_bytes), duration.as_millis(), @@ -675,7 +669,7 @@ fn print_retry_notice(delay: Duration, attempt: u32, max_attempts: u32) { fn print_transport_failure(duration: Duration, payload_size_bytes: usize, error: &reqwest::Error) { print!( - " {:>6} mbit/s | {:>5} in {:>4}ms -> error: {} (skipped) ", + " {:>6} mbit/s | {:>5} in {:>4}ms -> error: {} ", "N/A", format_bytes(payload_size_bytes), duration.as_millis(), From f775d079a5f05ab85694415f947e14a63a2627da Mon Sep 17 00:00:00 2001 From: Robin B Date: Sat, 14 Feb 2026 13:19:46 +0100 Subject: [PATCH 3/7] fix: show attempt counters only in verbose summary --- src/measurements.rs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/measurements.rs b/src/measurements.rs index e1a5cd1..0c3c9fa 100644 --- a/src/measurements.rs +++ b/src/measurements.rs @@ -70,7 +70,11 @@ pub(crate) fn log_measurements( ) { if output_format == OutputFormat::StdOut { println!("\nSummary Statistics"); - println!("Type Payload | min/max/avg in mbit/s | attempts/success/skipped"); + if verbose { + println!("Type Payload | min/max/avg in mbit/s | attempts/success/skipped"); + } else { + println!("Type Payload | min/max/avg in mbit/s"); + } } let mut stat_measurements: Vec = Vec::new(); let mut test_types = measurements @@ -189,9 +193,15 @@ fn log_measurements_by_test_type( target_successes, }); if output_format == OutputFormat::StdOut { - println!( - "{fmt_test_type:<9} {formatted_payload:<7}| min {min:<7.2} max {max:<7.2} avg {avg:<7.2} | {attempts:>3}/{successes:>3}/{skipped:>3}" - ); + if verbose { + println!( + "{fmt_test_type:<9} {formatted_payload:<7}| min {min:<7.2} max {max:<7.2} avg {avg:<7.2} | {attempts:>3}/{successes:>3}/{skipped:>3}" + ); + } else { + println!( + "{fmt_test_type:<9} {formatted_payload:<7}| min {min:<7.2} max {max:<7.2} avg {avg:<7.2}" + ); + } if successes < target_successes { println!( " insufficient samples: collected {successes}/{target_successes} successful runs" @@ -218,9 +228,15 @@ fn log_measurements_by_test_type( target_successes, }); if output_format == OutputFormat::StdOut { - println!( - "{fmt_test_type:<9} {formatted_payload:<7}| min N/A max N/A avg N/A | {attempts:>3}/{successes:>3}/{skipped:>3} (insufficient samples)" - ); + if verbose { + println!( + "{fmt_test_type:<9} {formatted_payload:<7}| min N/A max N/A avg N/A | {attempts:>3}/{successes:>3}/{skipped:>3} (insufficient samples)" + ); + } else { + println!( + "{fmt_test_type:<9} {formatted_payload:<7}| min N/A max N/A avg N/A (insufficient samples)" + ); + } } } } From be103e63d3c2c3cbf91493e88910d4ce80016421 Mon Sep 17 00:00:00 2001 From: Robin B Date: Sat, 14 Feb 2026 13:43:23 +0100 Subject: [PATCH 4/7] chore: update dependencies and fix clippy pipeline --- Cargo.lock | 240 +++++++++++++++++++++++------------------------ src/speedtest.rs | 83 ++++++++++------ 2 files changed, 172 insertions(+), 151 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac0874e..8f63423 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,27 +75,27 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.48" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -131,9 +131,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -141,9 +141,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -153,18 +153,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.65" +version = "4.5.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -174,9 +174,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -218,9 +218,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -228,9 +228,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -247,9 +247,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "form_urlencoded" @@ -312,9 +312,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -428,14 +428,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -498,9 +497,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -512,9 +511,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -570,9 +569,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -586,15 +585,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.16" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" dependencies = [ "jiff-static", "log", @@ -605,9 +604,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.16" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" dependencies = [ "proc-macro2", "quote", @@ -616,9 +615,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -626,9 +625,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "litemap" @@ -650,15 +649,15 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -697,15 +696,15 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -730,9 +729,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -794,9 +793,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -829,18 +828,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -850,9 +849,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -861,9 +860,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" @@ -913,7 +912,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -927,9 +926,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", "ring", @@ -941,9 +940,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -951,9 +950,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -968,9 +967,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "serde" @@ -1036,9 +1035,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -1048,9 +1047,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -1076,9 +1075,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -1107,18 +1106,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1152,9 +1151,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -1176,9 +1175,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -1221,9 +1220,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-core", @@ -1231,9 +1230,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -1246,9 +1245,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "untrusted" @@ -1258,9 +1257,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -1297,18 +1296,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -1319,11 +1318,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -1332,9 +1332,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1342,9 +1342,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -1355,18 +1355,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -1384,9 +1384,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -1555,9 +1555,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -1590,18 +1590,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", @@ -1670,6 +1670,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.10" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/speedtest.rs b/src/speedtest.rs index 846a809..4fbbe8f 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -26,6 +26,13 @@ const MAX_ATTEMPT_FACTOR: u32 = 4; const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(250); const RETRY_MAX_BACKOFF: Duration = Duration::from_secs(3); +#[derive(Clone, Copy, Debug)] +struct RetryRunOptions { + nr_tests: u32, + output_format: OutputFormat, + disable_dynamic_max_payload_size: bool, +} + #[derive(Clone, Copy, Debug, Hash, Serialize, Eq, PartialEq)] pub enum TestType { Download, @@ -300,13 +307,16 @@ pub fn run_tests_with_retries( output_format: OutputFormat, disable_dynamic_max_payload_size: bool, ) -> (Vec, Vec) { + let options = RetryRunOptions { + nr_tests, + output_format, + disable_dynamic_max_payload_size, + }; run_tests_with_sleep( client, test_type, payload_sizes, - nr_tests, - output_format, - disable_dynamic_max_payload_size, + options, BASE_URL, thread::sleep, ) @@ -316,9 +326,7 @@ fn run_tests_with_sleep( client: &Client, test_type: TestType, payload_sizes: Vec, - nr_tests: u32, - output_format: OutputFormat, - disable_dynamic_max_payload_size: bool, + options: RetryRunOptions, base_url: &str, sleep_fn: S, ) -> (Vec, Vec) @@ -336,20 +344,26 @@ where let mut attempts = 0; let mut successes = 0; let mut skipped = 0; - let max_attempts = nr_tests.saturating_mul(MAX_ATTEMPT_FACTOR).max(nr_tests); - - while successes < nr_tests && attempts < max_attempts { - if output_format == OutputFormat::StdOut { - print_progress(&label, successes, nr_tests); + let max_attempts = options + .nr_tests + .saturating_mul(MAX_ATTEMPT_FACTOR) + .max(options.nr_tests); + + while successes < options.nr_tests && attempts < max_attempts { + if options.output_format == OutputFormat::StdOut { + print_progress(&label, successes, options.nr_tests); } attempts += 1; let sample_outcome = match test_type { - TestType::Download => { - test_download_with_base_url(client, payload_size, output_format, base_url) - } + TestType::Download => test_download_with_base_url( + client, + payload_size, + options.output_format, + base_url, + ), TestType::Upload => { - test_upload_with_base_url(client, payload_size, output_format, base_url) + test_upload_with_base_url(client, payload_size, options.output_format, base_url) } }; @@ -390,7 +404,7 @@ where duration.as_millis(), delay.as_millis(), ); - if output_format == OutputFormat::StdOut { + if options.output_format == OutputFormat::StdOut { print_retry_notice(delay, attempts, max_attempts); } sleep_fn(delay); @@ -415,8 +429,8 @@ where } } - if output_format == OutputFormat::StdOut { - print_progress(&label, successes, nr_tests); + if options.output_format == OutputFormat::StdOut { + print_progress(&label, successes, options.nr_tests); println!(); } @@ -426,18 +440,19 @@ where attempts, successes, skipped, - target_successes: nr_tests, + target_successes: options.nr_tests, }); - if successes < nr_tests { + if successes < options.nr_tests { log::warn!( - "{test_type:?} {} collected {successes}/{nr_tests} successful samples after {attempts} attempts", + "{test_type:?} {} collected {successes}/{} successful samples after {attempts} attempts", format_bytes(payload_size), + options.nr_tests, ); } let duration = start.elapsed(); - if !disable_dynamic_max_payload_size && duration > TIME_THRESHOLD { + if !options.disable_dynamic_max_payload_size && duration > TIME_THRESHOLD { log::info!("Exceeded threshold"); break; } @@ -1017,9 +1032,11 @@ mod tests { &client, TestType::Download, vec![100_000], - 1, - OutputFormat::None, - true, + RetryRunOptions { + nr_tests: 1, + output_format: OutputFormat::None, + disable_dynamic_max_payload_size: true, + }, &base_url, |_| {}, ); @@ -1054,9 +1071,11 @@ mod tests { &client, TestType::Download, vec![100_000], - 2, - OutputFormat::None, - true, + RetryRunOptions { + nr_tests: 2, + output_format: OutputFormat::None, + disable_dynamic_max_payload_size: true, + }, &base_url, |_| {}, ); @@ -1089,9 +1108,11 @@ mod tests { &client, TestType::Download, vec![100_000], - 2, - OutputFormat::None, - true, + RetryRunOptions { + nr_tests: 2, + output_format: OutputFormat::None, + disable_dynamic_max_payload_size: true, + }, &base_url, |_| {}, ); From 1eb339cbdefe3ff564611874cb86226fc1334d11 Mon Sep 17 00:00:00 2001 From: Robin B Date: Sat, 14 Feb 2026 22:12:51 +0100 Subject: [PATCH 5/7] fix: show retry ETA and flush progress output --- Cargo.lock | 18 ++++++++++++++++++ Cargo.toml | 1 + src/speedtest.rs | 42 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f63423..323f1fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,7 @@ dependencies = [ "csv", "env_logger", "indexmap", + "jiff", "log", "regex", "reqwest", @@ -596,10 +597,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" dependencies = [ "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-sys 0.52.0", ] [[package]] @@ -613,6 +616,21 @@ dependencies = [ "syn", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.85" diff --git a/Cargo.toml b/Cargo.toml index 324313c..70216da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ csv = "1.3.0" serde_json = { version = "1.0", features = ["preserve_order"] } indexmap = "2.13" clap_complete = "4.5" +jiff = "0.2" diff --git a/src/speedtest.rs b/src/speedtest.rs index 4fbbe8f..323d167 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -6,12 +6,14 @@ use crate::measurements::PayloadAttemptStats; use crate::progress::print_progress; use crate::OutputFormat; use crate::SpeedTestCLIOptions; +use jiff::Zoned; use log; use regex::Regex; use reqwest::{blocking::Client, header::RETRY_AFTER, StatusCode}; use serde::Serialize; use std::{ fmt::Display, + io::Write, sync::atomic::{AtomicBool, Ordering}, thread, time::{Duration, Instant}, @@ -661,6 +663,7 @@ fn print_current_speed(mbits: f64, duration: Duration, payload_size_bytes: usize format_bytes(payload_size_bytes), duration.as_millis(), ); + flush_stdout(); } fn print_skipped_sample(duration: Duration, status_code: StatusCode, payload_size_bytes: usize) { @@ -671,15 +674,17 @@ fn print_skipped_sample(duration: Duration, status_code: StatusCode, payload_siz duration.as_millis(), status_code ); + flush_stdout(); } fn print_retry_notice(delay: Duration, attempt: u32, max_attempts: u32) { + let delay_display = format_retry_delay(delay); + let eta_display = format_retry_eta(delay); print!( - " retrying in {}ms ({}/{}) ", - delay.as_millis(), - attempt, - max_attempts + " retrying in {}{} ({}/{}) ", + delay_display, eta_display, attempt, max_attempts ); + flush_stdout(); } fn print_transport_failure(duration: Duration, payload_size_bytes: usize, error: &reqwest::Error) { @@ -690,6 +695,35 @@ fn print_transport_failure(duration: Duration, payload_size_bytes: usize, error: duration.as_millis(), error ); + flush_stdout(); +} + +fn format_retry_delay(delay: Duration) -> String { + let total_seconds = delay.as_secs(); + if total_seconds == 0 { + return format!("{}ms", delay.as_millis()); + } + if total_seconds < 60 { + return format!("{total_seconds}s"); + } + if total_seconds < 3600 { + return format!("{}m {:02}s", total_seconds / 60, total_seconds % 60); + } + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + format!("{hours}h {minutes:02}m") +} + +fn format_retry_eta(delay: Duration) -> String { + if delay.as_secs() < 60 { + return String::new(); + } + let eta = Zoned::now().saturating_add(delay); + format!(" (until {})", eta.strftime("%H:%M:%S %Z")) +} + +fn flush_stdout() { + let _ = std::io::stdout().flush(); } pub fn fetch_metadata(client: &Client) -> Result { From 80167c8c0f7b71066055e3dc1dca9f04111bf22b Mon Sep 17 00:00:00 2001 From: Robin B Date: Sat, 14 Feb 2026 22:44:19 +0100 Subject: [PATCH 6/7] fix: measure upload duration before response drain --- src/speedtest.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/src/speedtest.rs b/src/speedtest.rs index 323d167..3ff30ec 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -516,14 +516,15 @@ fn test_upload_with_base_url( }; let status_code = response.status(); + let retry_after = parse_retry_after(response.headers().get(RETRY_AFTER)); + // Measure upload duration once response headers are available. + let duration = start.elapsed(); // Drain response after timing so we don't skew upload measurement. let _ = std::io::copy(&mut response, &mut std::io::sink()); - let duration = start.elapsed(); if !status_code.is_success() { if output_format == OutputFormat::StdOut { print_skipped_sample(duration, status_code, payload_size_bytes); } - let retry_after = parse_retry_after(response.headers().get(RETRY_AFTER)); return if is_retryable_status(status_code) { SampleOutcome::RetryableFailure { duration, @@ -818,7 +819,14 @@ mod tests { response.reason, response.body.len(), ); + let mut delay_before_body = Duration::ZERO; for (header, value) in &response.headers { + if header.eq_ignore_ascii_case("X-Test-Delay-Ms") { + if let Ok(ms) = value.parse::() { + delay_before_body = Duration::from_millis(ms); + } + continue; + } response_head.push_str(&format!("{header}: {value}\r\n")); } response_head.push_str("\r\n"); @@ -826,6 +834,9 @@ mod tests { stream .write_all(response_head.as_bytes()) .expect("failed to write mock response head"); + if !delay_before_body.is_zero() { + thread::sleep(delay_before_body); + } if !response.body.is_empty() { stream .write_all(response.body.as_bytes()) @@ -1161,6 +1172,85 @@ mod tests { assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 1); } + #[test] + fn test_upload_duration_excludes_delayed_response_body() { + let responses = vec![MockHttpResponse { + status_code: 200, + reason: "OK", + headers: vec![("X-Test-Delay-Ms", "300")], + body: "ok", + }]; + let (base_url, served_counter, handle) = spawn_mock_http_server(responses); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("failed to build test client"); + + let wall_start = Instant::now(); + let outcome = test_upload_with_base_url(&client, 100_000, OutputFormat::None, &base_url); + let wall_elapsed = wall_start.elapsed(); + + handle.join().expect("mock server thread panicked"); + assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 1); + + match outcome { + SampleOutcome::Success { duration, .. } => { + assert!( + duration < Duration::from_millis(200), + "upload duration should stop before delayed response drain: {duration:?}" + ); + assert!( + wall_elapsed >= Duration::from_millis(250), + "overall call should include delayed body drain: {wall_elapsed:?}" + ); + } + other => panic!("expected upload success, got {other:?}"), + } + } + + #[test] + fn test_upload_retryable_failure_parses_retry_after_without_drain_skew() { + let responses = vec![MockHttpResponse { + status_code: 429, + reason: "Too Many Requests", + headers: vec![("Retry-After", "1"), ("X-Test-Delay-Ms", "300")], + body: "retry later", + }]; + let (base_url, served_counter, handle) = spawn_mock_http_server(responses); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("failed to build test client"); + + let wall_start = Instant::now(); + let outcome = test_upload_with_base_url(&client, 100_000, OutputFormat::None, &base_url); + let wall_elapsed = wall_start.elapsed(); + + handle.join().expect("mock server thread panicked"); + assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 1); + + match outcome { + SampleOutcome::RetryableFailure { + duration, + status_code, + retry_after, + .. + } => { + assert!( + duration < Duration::from_millis(200), + "retryable failure duration should stop before delayed body drain: {duration:?}" + ); + assert_eq!(status_code, Some(StatusCode::TOO_MANY_REQUESTS)); + assert_eq!(retry_after, Some(Duration::from_secs(1))); + assert!( + wall_elapsed >= Duration::from_millis(250), + "overall call should include delayed body drain: {wall_elapsed:?}" + ); + } + other => panic!("expected retryable upload failure, got {other:?}"), + } + } + #[test] fn test_fetch_metadata_integration() { // This test verifies that Cloudflare's trace endpoint returns the expected metadata fields. From ac88be1698c7027a2ce4c2da8028c231946ae191 Mon Sep 17 00:00:00 2001 From: Robin B Date: Mon, 16 Feb 2026 22:19:04 +0100 Subject: [PATCH 7/7] fix: base retry backoff on retry streak --- src/speedtest.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 5 deletions(-) diff --git a/src/speedtest.rs b/src/speedtest.rs index 3ff30ec..99b19a6 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -346,6 +346,7 @@ where let mut attempts = 0; let mut successes = 0; let mut skipped = 0; + let mut retry_streak = 0; let max_attempts = options .nr_tests .saturating_mul(MAX_ATTEMPT_FACTOR) @@ -382,6 +383,7 @@ where duration.as_millis(), ); successes += 1; + retry_streak = 0; measurements.push(Measurement { test_type, payload_size, @@ -395,8 +397,9 @@ where reason, } => { skipped += 1; + retry_streak += 1; if attempts < max_attempts { - let delay = compute_retry_delay(attempts, retry_after); + let delay = compute_retry_delay(retry_streak, retry_after); let status = status_code .map(|code| code.to_string()) .unwrap_or_else(|| "transport error".to_string()); @@ -635,12 +638,12 @@ fn parse_retry_after(retry_after: Option<&reqwest::header::HeaderValue>) -> Opti .map(Duration::from_secs) } -fn compute_retry_delay(attempt: u32, retry_after: Option) -> Duration { +fn compute_retry_delay(retry_count: u32, retry_after: Option) -> Duration { if let Some(delay) = retry_after { return delay; } - let exponent = attempt.saturating_sub(1).min(4); + let exponent = retry_count.saturating_sub(1).min(4); let base_delay_ms = RETRY_BASE_BACKOFF.as_millis() as u64; let capped_delay_ms = RETRY_MAX_BACKOFF.as_millis() as u64; let delay_ms = base_delay_ms @@ -648,7 +651,7 @@ fn compute_retry_delay(attempt: u32, retry_after: Option) -> Duration .min(capped_delay_ms); let jitter = delay_ms / 5; - let jittered_delay = if attempt.is_multiple_of(2) { + let jittered_delay = if retry_count.is_multiple_of(2) { delay_ms.saturating_add(jitter).min(capped_delay_ms) } else { delay_ms.saturating_sub(jitter) @@ -779,7 +782,7 @@ mod tests { use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -1096,6 +1099,142 @@ mod tests { assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 2); } + #[test] + fn test_run_tests_retry_delay_uses_retry_streak_not_total_attempts() { + let mut responses = (0..5) + .map(|_| MockHttpResponse { + status_code: 200, + reason: "OK", + headers: vec![], + body: "ok", + }) + .collect::>(); + responses.push(MockHttpResponse { + status_code: 429, + reason: "Too Many Requests", + headers: vec![], + body: "", + }); + responses.push(MockHttpResponse { + status_code: 200, + reason: "OK", + headers: vec![], + body: "ok", + }); + + let (base_url, served_counter, handle) = spawn_mock_http_server(responses); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("failed to build test client"); + let observed_delays = Arc::new(Mutex::new(Vec::::new())); + let delay_sink = Arc::clone(&observed_delays); + + let (measurements, payload_stats) = run_tests_with_sleep( + &client, + TestType::Download, + vec![100_000], + RetryRunOptions { + nr_tests: 6, + output_format: OutputFormat::None, + disable_dynamic_max_payload_size: true, + }, + &base_url, + move |delay| { + delay_sink + .lock() + .expect("failed to lock delay sink") + .push(delay); + }, + ); + + assert_eq!(measurements.len(), 6); + assert_eq!(payload_stats.len(), 1); + assert_eq!(payload_stats[0].attempts, 7); + assert_eq!(payload_stats[0].successes, 6); + assert_eq!(payload_stats[0].skipped, 1); + assert_eq!( + *observed_delays + .lock() + .expect("failed to read observed delays"), + vec![compute_retry_delay(1, None)] + ); + + handle.join().expect("mock server thread panicked"); + assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 7); + } + + #[test] + fn test_run_tests_retry_delay_resets_after_success() { + let responses = vec![ + MockHttpResponse { + status_code: 429, + reason: "Too Many Requests", + headers: vec![], + body: "", + }, + MockHttpResponse { + status_code: 200, + reason: "OK", + headers: vec![], + body: "ok", + }, + MockHttpResponse { + status_code: 429, + reason: "Too Many Requests", + headers: vec![], + body: "", + }, + MockHttpResponse { + status_code: 200, + reason: "OK", + headers: vec![], + body: "ok", + }, + ]; + + let (base_url, served_counter, handle) = spawn_mock_http_server(responses); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("failed to build test client"); + let observed_delays = Arc::new(Mutex::new(Vec::::new())); + let delay_sink = Arc::clone(&observed_delays); + + let (measurements, payload_stats) = run_tests_with_sleep( + &client, + TestType::Download, + vec![100_000], + RetryRunOptions { + nr_tests: 2, + output_format: OutputFormat::None, + disable_dynamic_max_payload_size: true, + }, + &base_url, + move |delay| { + delay_sink + .lock() + .expect("failed to lock delay sink") + .push(delay); + }, + ); + + assert_eq!(measurements.len(), 2); + assert_eq!(payload_stats.len(), 1); + assert_eq!(payload_stats[0].attempts, 4); + assert_eq!(payload_stats[0].successes, 2); + assert_eq!(payload_stats[0].skipped, 2); + assert_eq!( + *observed_delays + .lock() + .expect("failed to read observed delays"), + vec![compute_retry_delay(1, None), compute_retry_delay(1, None)] + ); + + handle.join().expect("mock server thread panicked"); + assert_eq!(served_counter.load(AtomicOrdering::SeqCst), 4); + } + #[test] fn test_run_tests_stops_after_max_attempts_on_retryable_failures() { let responses = (0..8)