1use async_stream::stream;
38use futures::StreamExt;
39use std::{
40 collections::HashMap,
41 sync::{
42 atomic::{AtomicU64, Ordering},
43 Arc,
44 },
45 time::{Duration, Instant},
46};
47use tokio::sync::Mutex;
48
49#[derive(Debug, Clone)]
51pub struct ChunkedBenchConfig {
52 pub target_url: String,
54 pub method: reqwest::Method,
56 pub concurrency: u32,
58 pub duration: Duration,
60 pub chunk_size_bytes: usize,
62 pub total_size_bytes: usize,
64 pub chunk_interval_ms: u64,
66 pub headers: HashMap<String, String>,
69 pub skip_tls_verify: bool,
71 pub rps: Option<u32>,
75 pub no_keep_alive: bool,
78 pub body: Option<Arc<Vec<u8>>>,
82}
83
84impl ChunkedBenchConfig {
85 fn body_len(&self) -> usize {
87 self.body.as_ref().map_or(self.total_size_bytes, |b| b.len())
88 }
89}
90
91pub const JSON_PADDING_FIELD: &str = "_padding";
93
94pub fn is_json_content_type(headers: &HashMap<String, String>) -> bool {
97 headers.iter().any(|(k, v)| {
98 if !k.eq_ignore_ascii_case("content-type") {
99 return false;
100 }
101 let media = v.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
102 media == "application/json" || media.ends_with("+json")
103 })
104}
105
106pub fn build_json_body(seed: Option<&serde_json::Value>, total: usize) -> Vec<u8> {
114 let mut obj = match seed {
115 Some(serde_json::Value::Object(m)) => m.clone(),
116 Some(other) => {
117 let mut m = serde_json::Map::new();
118 m.insert("data".to_string(), other.clone());
119 m
120 }
121 None => serde_json::Map::new(),
122 };
123 obj.insert(JSON_PADDING_FIELD.to_string(), serde_json::Value::String(String::new()));
124 let base = serde_json::to_vec(&obj).unwrap_or_default().len();
125 let pad = total.saturating_sub(base);
127 obj.insert(JSON_PADDING_FIELD.to_string(), serde_json::Value::String("X".repeat(pad)));
128 serde_json::to_vec(&obj).unwrap_or_default()
129}
130
131struct Pacer {
133 interval: Duration,
134 next: Mutex<Instant>,
135}
136
137impl Pacer {
138 fn new(rps: u32) -> Self {
139 Self {
140 interval: Duration::from_secs_f64(1.0 / f64::from(rps)),
141 next: Mutex::new(Instant::now()),
142 }
143 }
144
145 async fn reserve(&self) -> Instant {
148 let mut next = self.next.lock().await;
149 let slot = (*next).max(Instant::now());
150 *next = slot + self.interval;
151 slot
152 }
153}
154
155#[derive(Debug, Clone)]
160pub struct ErrorSample {
161 pub status: u16,
162 pub server_header: Option<String>,
165 pub body_excerpt: String,
167}
168
169#[derive(Debug, Clone)]
171pub struct ChunkedBenchResult {
172 pub total_requests: u64,
173 pub successful: u64,
174 pub failed: u64,
175 pub bytes_sent: u64,
176 pub elapsed: Duration,
177 pub req_per_sec: f64,
180 pub load_window: Duration,
182 pub start_rate_per_sec: f64,
186 pub upload_mbps: f64,
188 pub latencies_ms: Vec<u64>,
189 pub avg_latency_ms: f64,
190 pub p50_ms: u64,
191 pub p95_ms: u64,
192 pub p99_ms: u64,
193 pub status_counts: HashMap<u16, u64>,
194 pub error_samples: Vec<ErrorSample>,
197}
198
199const MAX_ERROR_SAMPLES: usize = 5;
201const ERROR_BODY_EXCERPT_BYTES: usize = 256;
203
204pub async fn run(cfg: ChunkedBenchConfig) -> anyhow::Result<ChunkedBenchResult> {
207 if cfg.chunk_size_bytes == 0 {
208 anyhow::bail!("chunk_size_bytes must be > 0");
209 }
210 if cfg.total_size_bytes == 0 {
211 anyhow::bail!("total_size_bytes must be > 0");
212 }
213 if cfg.concurrency == 0 {
214 anyhow::bail!("concurrency must be >= 1");
215 }
216 if cfg.rps == Some(0) {
217 anyhow::bail!("rps must be >= 1");
218 }
219
220 let mut builder = reqwest::Client::builder().danger_accept_invalid_certs(cfg.skip_tls_verify);
221 if cfg.no_keep_alive {
222 builder = builder.pool_max_idle_per_host(0);
223 }
224 let client = builder.build()?;
225 let pacer = cfg.rps.map(|n| Arc::new(Pacer::new(n)));
226
227 let total_requests = Arc::new(AtomicU64::new(0));
228 let successful = Arc::new(AtomicU64::new(0));
229 let failed = Arc::new(AtomicU64::new(0));
230 let bytes_sent = Arc::new(AtomicU64::new(0));
231 let latencies: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::with_capacity(8192)));
232 let status_counts: Arc<Mutex<HashMap<u16, u64>>> = Arc::new(Mutex::new(HashMap::new()));
233 let error_samples: Arc<Mutex<Vec<ErrorSample>>> = Arc::new(Mutex::new(Vec::new()));
234
235 let deadline = Instant::now() + cfg.duration;
236 let started_at = Instant::now();
237
238 let mut workers = Vec::with_capacity(cfg.concurrency as usize);
239 for _ in 0..cfg.concurrency {
240 let cfg = cfg.clone();
241 let client = client.clone();
242 let total_requests = total_requests.clone();
243 let successful = successful.clone();
244 let failed = failed.clone();
245 let bytes_sent = bytes_sent.clone();
246 let latencies = latencies.clone();
247 let status_counts = status_counts.clone();
248 let error_samples = error_samples.clone();
249 let pacer = pacer.clone();
250
251 workers.push(tokio::spawn(async move {
252 while Instant::now() < deadline {
253 if let Some(pacer) = &pacer {
254 let slot = pacer.reserve().await;
255 if slot >= deadline {
256 break;
257 }
258 tokio::time::sleep_until(slot.into()).await;
259 }
260 let req_started = Instant::now();
261 match send_one_chunked_request(&client, &cfg).await {
262 Ok(SendResult { status, sample }) => {
263 successful.fetch_add(1, Ordering::Relaxed);
264 bytes_sent.fetch_add(cfg.body_len() as u64, Ordering::Relaxed);
265 let elapsed_ms = req_started.elapsed().as_millis() as u64;
266 latencies.lock().await.push(elapsed_ms);
267 *status_counts.lock().await.entry(status).or_insert(0) += 1;
268 if let Some(s) = sample {
269 let mut g = error_samples.lock().await;
270 if g.len() < MAX_ERROR_SAMPLES {
271 g.push(s);
272 }
273 }
274 }
275 Err(_e) => {
276 failed.fetch_add(1, Ordering::Relaxed);
277 }
278 }
279 total_requests.fetch_add(1, Ordering::Relaxed);
280 }
281 }));
282 }
283
284 for w in workers {
285 let _ = w.await;
286 }
287
288 let elapsed = started_at.elapsed();
289 let load_window = cfg.duration;
290 let total = total_requests.load(Ordering::Relaxed);
291 let sent = bytes_sent.load(Ordering::Relaxed);
292 let mut samples: Vec<u64> = {
293 let mut g = latencies.lock().await;
294 std::mem::take(&mut *g)
295 };
296 let final_status_counts: HashMap<u16, u64> = {
297 let mut g = status_counts.lock().await;
298 std::mem::take(&mut *g)
299 };
300 let final_error_samples: Vec<ErrorSample> = {
301 let mut g = error_samples.lock().await;
302 std::mem::take(&mut *g)
303 };
304 samples.sort_unstable();
305 let avg = if samples.is_empty() {
306 0.0
307 } else {
308 samples.iter().copied().sum::<u64>() as f64 / samples.len() as f64
309 };
310 let p = |q: f64| -> u64 {
311 if samples.is_empty() {
312 return 0;
313 }
314 let idx = ((samples.len() as f64 - 1.0) * q).round() as usize;
315 samples[idx]
316 };
317
318 Ok(ChunkedBenchResult {
319 total_requests: total,
320 successful: successful.load(Ordering::Relaxed),
321 failed: failed.load(Ordering::Relaxed),
322 bytes_sent: sent,
323 elapsed,
324 req_per_sec: per_second(total as f64, elapsed),
325 load_window,
326 start_rate_per_sec: per_second(total as f64, load_window),
327 upload_mbps: per_second(sent as f64 * 8.0, elapsed) / 1_000_000.0,
328 avg_latency_ms: avg,
329 p50_ms: p(0.50),
330 p95_ms: p(0.95),
331 p99_ms: p(0.99),
332 latencies_ms: samples,
333 status_counts: final_status_counts,
334 error_samples: final_error_samples,
335 })
336}
337
338pub(crate) fn per_second(amount: f64, window: Duration) -> f64 {
339 if window.is_zero() {
340 0.0
341 } else {
342 amount / window.as_secs_f64()
343 }
344}
345
346struct SendResult {
350 status: u16,
351 sample: Option<ErrorSample>,
352}
353
354async fn send_one_chunked_request(
355 client: &reqwest::Client,
356 cfg: &ChunkedBenchConfig,
357) -> anyhow::Result<SendResult> {
358 let chunk_size = cfg.chunk_size_bytes;
359 let total = cfg.body_len();
360 let interval_ms = cfg.chunk_interval_ms;
361 let prebuilt = cfg.body.clone();
362
363 let body_stream = stream! {
368 let mut sent: usize = 0;
369 let filler = vec![b'X'; chunk_size];
370 while sent < total {
371 if interval_ms > 0 && sent > 0 {
373 tokio::time::sleep(Duration::from_millis(interval_ms)).await;
374 }
375 let next = std::cmp::min(chunk_size, total - sent);
376 let chunk = match &prebuilt {
377 Some(b) => b[sent..sent + next].to_vec(),
378 None => filler[..next].to_vec(),
379 };
380 sent += next;
381 yield Ok::<_, std::io::Error>(chunk);
382 }
383 };
384
385 let body = reqwest::Body::wrap_stream(body_stream.boxed());
386
387 let mut req = client.request(cfg.method.clone(), &cfg.target_url).body(body);
388 for (k, v) in &cfg.headers {
389 req = req.header(k, v);
390 }
391 let resp = req.send().await?;
392 let status = resp.status().as_u16();
393
394 let sample = if !(200..300).contains(&status) {
399 let server_header = resp
400 .headers()
401 .get(reqwest::header::SERVER)
402 .and_then(|v| v.to_str().ok())
403 .map(str::to_owned);
404 let bytes = resp.bytes().await.unwrap_or_default();
405 let take = std::cmp::min(bytes.len(), ERROR_BODY_EXCERPT_BYTES);
406 let body_excerpt = String::from_utf8_lossy(&bytes[..take]).trim().to_owned();
407 Some(ErrorSample {
408 status,
409 server_header,
410 body_excerpt,
411 })
412 } else {
413 None
414 };
415
416 Ok(SendResult { status, sample })
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn error_sample_struct_holds_diagnostic_fields() {
425 let s = ErrorSample {
428 status: 503,
429 server_header: Some("nginx/1.21.0".into()),
430 body_excerpt: "upstream timed out".into(),
431 };
432 assert_eq!(s.status, 503);
433 assert_eq!(s.server_header.as_deref(), Some("nginx/1.21.0"));
434 assert_eq!(s.body_excerpt, "upstream timed out");
435 }
436
437 #[tokio::test]
438 async fn rejects_zero_concurrency() {
439 let cfg = ChunkedBenchConfig {
440 target_url: "http://127.0.0.1:1".into(),
441 method: reqwest::Method::POST,
442 concurrency: 0,
443 duration: Duration::from_millis(10),
444 chunk_size_bytes: 1024,
445 total_size_bytes: 4096,
446 chunk_interval_ms: 0,
447 headers: HashMap::new(),
448 skip_tls_verify: false,
449 rps: None,
450 no_keep_alive: false,
451 body: None,
452 };
453 assert!(run(cfg).await.is_err());
454 }
455
456 #[tokio::test]
457 async fn rejects_zero_chunk_size() {
458 let cfg = ChunkedBenchConfig {
459 target_url: "http://127.0.0.1:1".into(),
460 method: reqwest::Method::POST,
461 concurrency: 1,
462 duration: Duration::from_millis(10),
463 chunk_size_bytes: 0,
464 total_size_bytes: 4096,
465 chunk_interval_ms: 0,
466 headers: HashMap::new(),
467 skip_tls_verify: false,
468 rps: None,
469 no_keep_alive: false,
470 body: None,
471 };
472 assert!(run(cfg).await.is_err());
473 }
474
475 #[tokio::test]
476 async fn pacer_spaces_slots_evenly() {
477 let pacer = Pacer::new(10);
478 let a = pacer.reserve().await;
479 let b = pacer.reserve().await;
480 let c = pacer.reserve().await;
481 assert_eq!(b - a, Duration::from_millis(100));
482 assert_eq!(c - b, Duration::from_millis(100));
483 }
484
485 #[tokio::test]
486 async fn rps_caps_request_starts_across_workers() {
487 let mut server = mockito::Server::new_async().await;
488 let _m = server
489 .mock("POST", "/upload")
490 .with_status(200)
491 .expect_at_least(1)
492 .create_async()
493 .await;
494 let cfg = ChunkedBenchConfig {
495 target_url: format!("{}/upload", server.url()),
496 method: reqwest::Method::POST,
497 concurrency: 8,
498 duration: Duration::from_millis(1000),
499 chunk_size_bytes: 64,
500 total_size_bytes: 256,
501 chunk_interval_ms: 0,
502 headers: HashMap::new(),
503 skip_tls_verify: false,
504 rps: Some(5),
505 no_keep_alive: true,
506 body: None,
507 };
508 let r = run(cfg).await.unwrap();
509 assert!((4..=6).contains(&r.total_requests), "got {} requests", r.total_requests);
512 assert_eq!(r.failed, 0);
513 assert_eq!(r.load_window, Duration::from_millis(1000));
515 assert!((r.start_rate_per_sec - r.total_requests as f64).abs() < 1e-9);
516 assert!(r.start_rate_per_sec <= 5.0 + 1e-9);
517 }
518
519 #[tokio::test]
520 async fn rejects_zero_rps() {
521 let cfg = ChunkedBenchConfig {
522 target_url: "http://127.0.0.1:1".into(),
523 method: reqwest::Method::POST,
524 concurrency: 1,
525 duration: Duration::from_millis(10),
526 chunk_size_bytes: 1024,
527 total_size_bytes: 4096,
528 chunk_interval_ms: 0,
529 headers: HashMap::new(),
530 skip_tls_verify: false,
531 rps: Some(0),
532 no_keep_alive: false,
533 body: None,
534 };
535 assert!(run(cfg).await.is_err());
536 }
537
538 #[test]
539 fn json_body_is_valid_and_exact_size() {
540 let seed = serde_json::json!({"name": "widget", "count": 3});
541 for total in [64usize, 4096, 1_048_576] {
542 let body = build_json_body(Some(&seed), total);
543 assert_eq!(body.len(), total);
544 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
545 assert_eq!(v["name"], "widget");
546 assert_eq!(v["count"], 3);
547 }
548 }
549
550 #[test]
551 fn json_body_without_seed_or_non_object_seed() {
552 let body = build_json_body(None, 100);
553 assert_eq!(body.len(), 100);
554 assert!(serde_json::from_slice::<serde_json::Value>(&body).unwrap().is_object());
555
556 let body = build_json_body(Some(&serde_json::json!([1, 2])), 100);
557 assert_eq!(body.len(), 100);
558 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
559 assert_eq!(v["data"], serde_json::json!([1, 2]));
560 }
561
562 #[test]
563 fn json_body_smaller_than_minimum_stays_valid() {
564 let body = build_json_body(None, 1);
565 assert_eq!(body, br#"{"_padding":""}"#);
566 }
567
568 #[test]
569 fn detects_json_content_types() {
570 let h = |v: &str| HashMap::from([("content-type".to_string(), v.to_string())]);
571 assert!(is_json_content_type(&h("application/json")));
572 assert!(is_json_content_type(&h("Application/JSON; charset=utf-8")));
573 assert!(is_json_content_type(&h("application/vnd.api+json")));
574 assert!(!is_json_content_type(&h("application/octet-stream")));
575 assert!(!is_json_content_type(&HashMap::new()));
576 let upper = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
577 assert!(is_json_content_type(&upper));
578 }
579
580 #[tokio::test]
581 async fn streams_prebuilt_json_body_intact() {
582 let mut server = mockito::Server::new_async().await;
583 let m = server
584 .mock("POST", "/items")
585 .match_body(mockito::Matcher::PartialJson(serde_json::json!({"name": "widget"})))
586 .with_status(201)
587 .expect_at_least(1)
588 .create_async()
589 .await;
590 let body = build_json_body(Some(&serde_json::json!({"name": "widget"})), 10_000);
591 let cfg = ChunkedBenchConfig {
592 target_url: format!("{}/items", server.url()),
593 method: reqwest::Method::POST,
594 concurrency: 1,
595 duration: Duration::from_millis(300),
596 chunk_size_bytes: 777,
597 total_size_bytes: 10_000,
598 chunk_interval_ms: 0,
599 headers: HashMap::from([("Content-Type".into(), "application/json".into())]),
600 skip_tls_verify: false,
601 rps: None,
602 no_keep_alive: false,
603 body: Some(Arc::new(body)),
604 };
605 let r = run(cfg).await.unwrap();
606 assert_eq!(r.failed, 0);
607 assert!(r.status_counts.contains_key(&201), "statuses: {:?}", r.status_counts);
608 assert_eq!(r.bytes_sent, r.successful * 10_000);
609 m.assert_async().await;
610 }
611}