1use async_stream::stream;
37use futures::StreamExt;
38use std::{
39 collections::HashMap,
40 sync::{
41 atomic::{AtomicU64, Ordering},
42 Arc,
43 },
44 time::{Duration, Instant},
45};
46use tokio::sync::Mutex;
47
48#[derive(Debug, Clone)]
50pub struct ChunkedBenchConfig {
51 pub target_url: String,
53 pub method: reqwest::Method,
55 pub concurrency: u32,
57 pub duration: Duration,
59 pub chunk_size_bytes: usize,
61 pub total_size_bytes: usize,
63 pub chunk_interval_ms: u64,
65 pub headers: HashMap<String, String>,
68 pub skip_tls_verify: bool,
70 pub rps: Option<u32>,
74 pub no_keep_alive: bool,
77}
78
79struct Pacer {
81 interval: Duration,
82 next: Mutex<Instant>,
83}
84
85impl Pacer {
86 fn new(rps: u32) -> Self {
87 Self {
88 interval: Duration::from_secs_f64(1.0 / f64::from(rps)),
89 next: Mutex::new(Instant::now()),
90 }
91 }
92
93 async fn reserve(&self) -> Instant {
96 let mut next = self.next.lock().await;
97 let slot = (*next).max(Instant::now());
98 *next = slot + self.interval;
99 slot
100 }
101}
102
103#[derive(Debug, Clone)]
108pub struct ErrorSample {
109 pub status: u16,
110 pub server_header: Option<String>,
113 pub body_excerpt: String,
115}
116
117#[derive(Debug, Clone)]
119pub struct ChunkedBenchResult {
120 pub total_requests: u64,
121 pub successful: u64,
122 pub failed: u64,
123 pub bytes_sent: u64,
124 pub elapsed: Duration,
125 pub req_per_sec: f64,
126 pub latencies_ms: Vec<u64>,
127 pub avg_latency_ms: f64,
128 pub p50_ms: u64,
129 pub p95_ms: u64,
130 pub p99_ms: u64,
131 pub status_counts: HashMap<u16, u64>,
132 pub error_samples: Vec<ErrorSample>,
135}
136
137const MAX_ERROR_SAMPLES: usize = 5;
139const ERROR_BODY_EXCERPT_BYTES: usize = 256;
141
142pub async fn run(cfg: ChunkedBenchConfig) -> anyhow::Result<ChunkedBenchResult> {
145 if cfg.chunk_size_bytes == 0 {
146 anyhow::bail!("chunk_size_bytes must be > 0");
147 }
148 if cfg.total_size_bytes == 0 {
149 anyhow::bail!("total_size_bytes must be > 0");
150 }
151 if cfg.concurrency == 0 {
152 anyhow::bail!("concurrency must be >= 1");
153 }
154 if cfg.rps == Some(0) {
155 anyhow::bail!("rps must be >= 1");
156 }
157
158 let mut builder = reqwest::Client::builder().danger_accept_invalid_certs(cfg.skip_tls_verify);
159 if cfg.no_keep_alive {
160 builder = builder.pool_max_idle_per_host(0);
161 }
162 let client = builder.build()?;
163 let pacer = cfg.rps.map(|n| Arc::new(Pacer::new(n)));
164
165 let total_requests = Arc::new(AtomicU64::new(0));
166 let successful = Arc::new(AtomicU64::new(0));
167 let failed = Arc::new(AtomicU64::new(0));
168 let bytes_sent = Arc::new(AtomicU64::new(0));
169 let latencies: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::with_capacity(8192)));
170 let status_counts: Arc<Mutex<HashMap<u16, u64>>> = Arc::new(Mutex::new(HashMap::new()));
171 let error_samples: Arc<Mutex<Vec<ErrorSample>>> = Arc::new(Mutex::new(Vec::new()));
172
173 let deadline = Instant::now() + cfg.duration;
174 let started_at = Instant::now();
175
176 let mut workers = Vec::with_capacity(cfg.concurrency as usize);
177 for _ in 0..cfg.concurrency {
178 let cfg = cfg.clone();
179 let client = client.clone();
180 let total_requests = total_requests.clone();
181 let successful = successful.clone();
182 let failed = failed.clone();
183 let bytes_sent = bytes_sent.clone();
184 let latencies = latencies.clone();
185 let status_counts = status_counts.clone();
186 let error_samples = error_samples.clone();
187 let pacer = pacer.clone();
188
189 workers.push(tokio::spawn(async move {
190 while Instant::now() < deadline {
191 if let Some(pacer) = &pacer {
192 let slot = pacer.reserve().await;
193 if slot >= deadline {
194 break;
195 }
196 tokio::time::sleep_until(slot.into()).await;
197 }
198 let req_started = Instant::now();
199 match send_one_chunked_request(&client, &cfg).await {
200 Ok(SendResult { status, sample }) => {
201 successful.fetch_add(1, Ordering::Relaxed);
202 bytes_sent.fetch_add(cfg.total_size_bytes as u64, Ordering::Relaxed);
203 let elapsed_ms = req_started.elapsed().as_millis() as u64;
204 latencies.lock().await.push(elapsed_ms);
205 *status_counts.lock().await.entry(status).or_insert(0) += 1;
206 if let Some(s) = sample {
207 let mut g = error_samples.lock().await;
208 if g.len() < MAX_ERROR_SAMPLES {
209 g.push(s);
210 }
211 }
212 }
213 Err(_e) => {
214 failed.fetch_add(1, Ordering::Relaxed);
215 }
216 }
217 total_requests.fetch_add(1, Ordering::Relaxed);
218 }
219 }));
220 }
221
222 for w in workers {
223 let _ = w.await;
224 }
225
226 let elapsed = started_at.elapsed();
227 let total = total_requests.load(Ordering::Relaxed);
228 let mut samples: Vec<u64> = {
229 let mut g = latencies.lock().await;
230 std::mem::take(&mut *g)
231 };
232 let final_status_counts: HashMap<u16, u64> = {
233 let mut g = status_counts.lock().await;
234 std::mem::take(&mut *g)
235 };
236 let final_error_samples: Vec<ErrorSample> = {
237 let mut g = error_samples.lock().await;
238 std::mem::take(&mut *g)
239 };
240 samples.sort_unstable();
241 let avg = if samples.is_empty() {
242 0.0
243 } else {
244 samples.iter().copied().sum::<u64>() as f64 / samples.len() as f64
245 };
246 let p = |q: f64| -> u64 {
247 if samples.is_empty() {
248 return 0;
249 }
250 let idx = ((samples.len() as f64 - 1.0) * q).round() as usize;
251 samples[idx]
252 };
253
254 Ok(ChunkedBenchResult {
255 total_requests: total,
256 successful: successful.load(Ordering::Relaxed),
257 failed: failed.load(Ordering::Relaxed),
258 bytes_sent: bytes_sent.load(Ordering::Relaxed),
259 elapsed,
260 req_per_sec: if elapsed.as_secs_f64() > 0.0 {
261 total as f64 / elapsed.as_secs_f64()
262 } else {
263 0.0
264 },
265 avg_latency_ms: avg,
266 p50_ms: p(0.50),
267 p95_ms: p(0.95),
268 p99_ms: p(0.99),
269 latencies_ms: samples,
270 status_counts: final_status_counts,
271 error_samples: final_error_samples,
272 })
273}
274
275struct SendResult {
279 status: u16,
280 sample: Option<ErrorSample>,
281}
282
283async fn send_one_chunked_request(
284 client: &reqwest::Client,
285 cfg: &ChunkedBenchConfig,
286) -> anyhow::Result<SendResult> {
287 let chunk_size = cfg.chunk_size_bytes;
288 let total = cfg.total_size_bytes;
289 let interval_ms = cfg.chunk_interval_ms;
290
291 let body_stream = stream! {
295 let mut sent: usize = 0;
296 let payload = vec![b'X'; chunk_size];
297 while sent < total {
298 if interval_ms > 0 && sent > 0 {
300 tokio::time::sleep(Duration::from_millis(interval_ms)).await;
301 }
302 let next = std::cmp::min(chunk_size, total - sent);
303 let chunk = payload[..next].to_vec();
304 sent += next;
305 yield Ok::<_, std::io::Error>(chunk);
306 }
307 };
308
309 let body = reqwest::Body::wrap_stream(body_stream.boxed());
310
311 let mut req = client.request(cfg.method.clone(), &cfg.target_url).body(body);
312 for (k, v) in &cfg.headers {
313 req = req.header(k, v);
314 }
315 let resp = req.send().await?;
316 let status = resp.status().as_u16();
317
318 let sample = if !(200..300).contains(&status) {
323 let server_header = resp
324 .headers()
325 .get(reqwest::header::SERVER)
326 .and_then(|v| v.to_str().ok())
327 .map(str::to_owned);
328 let bytes = resp.bytes().await.unwrap_or_default();
329 let take = std::cmp::min(bytes.len(), ERROR_BODY_EXCERPT_BYTES);
330 let body_excerpt = String::from_utf8_lossy(&bytes[..take]).trim().to_owned();
331 Some(ErrorSample {
332 status,
333 server_header,
334 body_excerpt,
335 })
336 } else {
337 None
338 };
339
340 Ok(SendResult { status, sample })
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn error_sample_struct_holds_diagnostic_fields() {
349 let s = ErrorSample {
352 status: 503,
353 server_header: Some("nginx/1.21.0".into()),
354 body_excerpt: "upstream timed out".into(),
355 };
356 assert_eq!(s.status, 503);
357 assert_eq!(s.server_header.as_deref(), Some("nginx/1.21.0"));
358 assert_eq!(s.body_excerpt, "upstream timed out");
359 }
360
361 #[tokio::test]
362 async fn rejects_zero_concurrency() {
363 let cfg = ChunkedBenchConfig {
364 target_url: "http://127.0.0.1:1".into(),
365 method: reqwest::Method::POST,
366 concurrency: 0,
367 duration: Duration::from_millis(10),
368 chunk_size_bytes: 1024,
369 total_size_bytes: 4096,
370 chunk_interval_ms: 0,
371 headers: HashMap::new(),
372 skip_tls_verify: false,
373 rps: None,
374 no_keep_alive: false,
375 };
376 assert!(run(cfg).await.is_err());
377 }
378
379 #[tokio::test]
380 async fn rejects_zero_chunk_size() {
381 let cfg = ChunkedBenchConfig {
382 target_url: "http://127.0.0.1:1".into(),
383 method: reqwest::Method::POST,
384 concurrency: 1,
385 duration: Duration::from_millis(10),
386 chunk_size_bytes: 0,
387 total_size_bytes: 4096,
388 chunk_interval_ms: 0,
389 headers: HashMap::new(),
390 skip_tls_verify: false,
391 rps: None,
392 no_keep_alive: false,
393 };
394 assert!(run(cfg).await.is_err());
395 }
396
397 #[tokio::test]
398 async fn pacer_spaces_slots_evenly() {
399 let pacer = Pacer::new(10);
400 let a = pacer.reserve().await;
401 let b = pacer.reserve().await;
402 let c = pacer.reserve().await;
403 assert_eq!(b - a, Duration::from_millis(100));
404 assert_eq!(c - b, Duration::from_millis(100));
405 }
406
407 #[tokio::test]
408 async fn rps_caps_request_starts_across_workers() {
409 let mut server = mockito::Server::new_async().await;
410 let _m = server
411 .mock("POST", "/upload")
412 .with_status(200)
413 .expect_at_least(1)
414 .create_async()
415 .await;
416 let cfg = ChunkedBenchConfig {
417 target_url: format!("{}/upload", server.url()),
418 method: reqwest::Method::POST,
419 concurrency: 8,
420 duration: Duration::from_millis(1000),
421 chunk_size_bytes: 64,
422 total_size_bytes: 256,
423 chunk_interval_ms: 0,
424 headers: HashMap::new(),
425 skip_tls_verify: false,
426 rps: Some(5),
427 no_keep_alive: true,
428 };
429 let r = run(cfg).await.unwrap();
430 assert!((4..=6).contains(&r.total_requests), "got {} requests", r.total_requests);
433 assert_eq!(r.failed, 0);
434 }
435
436 #[tokio::test]
437 async fn rejects_zero_rps() {
438 let cfg = ChunkedBenchConfig {
439 target_url: "http://127.0.0.1:1".into(),
440 method: reqwest::Method::POST,
441 concurrency: 1,
442 duration: Duration::from_millis(10),
443 chunk_size_bytes: 1024,
444 total_size_bytes: 4096,
445 chunk_interval_ms: 0,
446 headers: HashMap::new(),
447 skip_tls_verify: false,
448 rps: Some(0),
449 no_keep_alive: false,
450 };
451 assert!(run(cfg).await.is_err());
452 }
453}