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,
178 pub latencies_ms: Vec<u64>,
179 pub avg_latency_ms: f64,
180 pub p50_ms: u64,
181 pub p95_ms: u64,
182 pub p99_ms: u64,
183 pub status_counts: HashMap<u16, u64>,
184 pub error_samples: Vec<ErrorSample>,
187}
188
189const MAX_ERROR_SAMPLES: usize = 5;
191const ERROR_BODY_EXCERPT_BYTES: usize = 256;
193
194pub async fn run(cfg: ChunkedBenchConfig) -> anyhow::Result<ChunkedBenchResult> {
197 if cfg.chunk_size_bytes == 0 {
198 anyhow::bail!("chunk_size_bytes must be > 0");
199 }
200 if cfg.total_size_bytes == 0 {
201 anyhow::bail!("total_size_bytes must be > 0");
202 }
203 if cfg.concurrency == 0 {
204 anyhow::bail!("concurrency must be >= 1");
205 }
206 if cfg.rps == Some(0) {
207 anyhow::bail!("rps must be >= 1");
208 }
209
210 let mut builder = reqwest::Client::builder().danger_accept_invalid_certs(cfg.skip_tls_verify);
211 if cfg.no_keep_alive {
212 builder = builder.pool_max_idle_per_host(0);
213 }
214 let client = builder.build()?;
215 let pacer = cfg.rps.map(|n| Arc::new(Pacer::new(n)));
216
217 let total_requests = Arc::new(AtomicU64::new(0));
218 let successful = Arc::new(AtomicU64::new(0));
219 let failed = Arc::new(AtomicU64::new(0));
220 let bytes_sent = Arc::new(AtomicU64::new(0));
221 let latencies: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::with_capacity(8192)));
222 let status_counts: Arc<Mutex<HashMap<u16, u64>>> = Arc::new(Mutex::new(HashMap::new()));
223 let error_samples: Arc<Mutex<Vec<ErrorSample>>> = Arc::new(Mutex::new(Vec::new()));
224
225 let deadline = Instant::now() + cfg.duration;
226 let started_at = Instant::now();
227
228 let mut workers = Vec::with_capacity(cfg.concurrency as usize);
229 for _ in 0..cfg.concurrency {
230 let cfg = cfg.clone();
231 let client = client.clone();
232 let total_requests = total_requests.clone();
233 let successful = successful.clone();
234 let failed = failed.clone();
235 let bytes_sent = bytes_sent.clone();
236 let latencies = latencies.clone();
237 let status_counts = status_counts.clone();
238 let error_samples = error_samples.clone();
239 let pacer = pacer.clone();
240
241 workers.push(tokio::spawn(async move {
242 while Instant::now() < deadline {
243 if let Some(pacer) = &pacer {
244 let slot = pacer.reserve().await;
245 if slot >= deadline {
246 break;
247 }
248 tokio::time::sleep_until(slot.into()).await;
249 }
250 let req_started = Instant::now();
251 match send_one_chunked_request(&client, &cfg).await {
252 Ok(SendResult { status, sample }) => {
253 successful.fetch_add(1, Ordering::Relaxed);
254 bytes_sent.fetch_add(cfg.body_len() as u64, Ordering::Relaxed);
255 let elapsed_ms = req_started.elapsed().as_millis() as u64;
256 latencies.lock().await.push(elapsed_ms);
257 *status_counts.lock().await.entry(status).or_insert(0) += 1;
258 if let Some(s) = sample {
259 let mut g = error_samples.lock().await;
260 if g.len() < MAX_ERROR_SAMPLES {
261 g.push(s);
262 }
263 }
264 }
265 Err(_e) => {
266 failed.fetch_add(1, Ordering::Relaxed);
267 }
268 }
269 total_requests.fetch_add(1, Ordering::Relaxed);
270 }
271 }));
272 }
273
274 for w in workers {
275 let _ = w.await;
276 }
277
278 let elapsed = started_at.elapsed();
279 let total = total_requests.load(Ordering::Relaxed);
280 let mut samples: Vec<u64> = {
281 let mut g = latencies.lock().await;
282 std::mem::take(&mut *g)
283 };
284 let final_status_counts: HashMap<u16, u64> = {
285 let mut g = status_counts.lock().await;
286 std::mem::take(&mut *g)
287 };
288 let final_error_samples: Vec<ErrorSample> = {
289 let mut g = error_samples.lock().await;
290 std::mem::take(&mut *g)
291 };
292 samples.sort_unstable();
293 let avg = if samples.is_empty() {
294 0.0
295 } else {
296 samples.iter().copied().sum::<u64>() as f64 / samples.len() as f64
297 };
298 let p = |q: f64| -> u64 {
299 if samples.is_empty() {
300 return 0;
301 }
302 let idx = ((samples.len() as f64 - 1.0) * q).round() as usize;
303 samples[idx]
304 };
305
306 Ok(ChunkedBenchResult {
307 total_requests: total,
308 successful: successful.load(Ordering::Relaxed),
309 failed: failed.load(Ordering::Relaxed),
310 bytes_sent: bytes_sent.load(Ordering::Relaxed),
311 elapsed,
312 req_per_sec: if elapsed.as_secs_f64() > 0.0 {
313 total as f64 / elapsed.as_secs_f64()
314 } else {
315 0.0
316 },
317 avg_latency_ms: avg,
318 p50_ms: p(0.50),
319 p95_ms: p(0.95),
320 p99_ms: p(0.99),
321 latencies_ms: samples,
322 status_counts: final_status_counts,
323 error_samples: final_error_samples,
324 })
325}
326
327struct SendResult {
331 status: u16,
332 sample: Option<ErrorSample>,
333}
334
335async fn send_one_chunked_request(
336 client: &reqwest::Client,
337 cfg: &ChunkedBenchConfig,
338) -> anyhow::Result<SendResult> {
339 let chunk_size = cfg.chunk_size_bytes;
340 let total = cfg.body_len();
341 let interval_ms = cfg.chunk_interval_ms;
342 let prebuilt = cfg.body.clone();
343
344 let body_stream = stream! {
349 let mut sent: usize = 0;
350 let filler = vec![b'X'; chunk_size];
351 while sent < total {
352 if interval_ms > 0 && sent > 0 {
354 tokio::time::sleep(Duration::from_millis(interval_ms)).await;
355 }
356 let next = std::cmp::min(chunk_size, total - sent);
357 let chunk = match &prebuilt {
358 Some(b) => b[sent..sent + next].to_vec(),
359 None => filler[..next].to_vec(),
360 };
361 sent += next;
362 yield Ok::<_, std::io::Error>(chunk);
363 }
364 };
365
366 let body = reqwest::Body::wrap_stream(body_stream.boxed());
367
368 let mut req = client.request(cfg.method.clone(), &cfg.target_url).body(body);
369 for (k, v) in &cfg.headers {
370 req = req.header(k, v);
371 }
372 let resp = req.send().await?;
373 let status = resp.status().as_u16();
374
375 let sample = if !(200..300).contains(&status) {
380 let server_header = resp
381 .headers()
382 .get(reqwest::header::SERVER)
383 .and_then(|v| v.to_str().ok())
384 .map(str::to_owned);
385 let bytes = resp.bytes().await.unwrap_or_default();
386 let take = std::cmp::min(bytes.len(), ERROR_BODY_EXCERPT_BYTES);
387 let body_excerpt = String::from_utf8_lossy(&bytes[..take]).trim().to_owned();
388 Some(ErrorSample {
389 status,
390 server_header,
391 body_excerpt,
392 })
393 } else {
394 None
395 };
396
397 Ok(SendResult { status, sample })
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn error_sample_struct_holds_diagnostic_fields() {
406 let s = ErrorSample {
409 status: 503,
410 server_header: Some("nginx/1.21.0".into()),
411 body_excerpt: "upstream timed out".into(),
412 };
413 assert_eq!(s.status, 503);
414 assert_eq!(s.server_header.as_deref(), Some("nginx/1.21.0"));
415 assert_eq!(s.body_excerpt, "upstream timed out");
416 }
417
418 #[tokio::test]
419 async fn rejects_zero_concurrency() {
420 let cfg = ChunkedBenchConfig {
421 target_url: "http://127.0.0.1:1".into(),
422 method: reqwest::Method::POST,
423 concurrency: 0,
424 duration: Duration::from_millis(10),
425 chunk_size_bytes: 1024,
426 total_size_bytes: 4096,
427 chunk_interval_ms: 0,
428 headers: HashMap::new(),
429 skip_tls_verify: false,
430 rps: None,
431 no_keep_alive: false,
432 body: None,
433 };
434 assert!(run(cfg).await.is_err());
435 }
436
437 #[tokio::test]
438 async fn rejects_zero_chunk_size() {
439 let cfg = ChunkedBenchConfig {
440 target_url: "http://127.0.0.1:1".into(),
441 method: reqwest::Method::POST,
442 concurrency: 1,
443 duration: Duration::from_millis(10),
444 chunk_size_bytes: 0,
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 pacer_spaces_slots_evenly() {
458 let pacer = Pacer::new(10);
459 let a = pacer.reserve().await;
460 let b = pacer.reserve().await;
461 let c = pacer.reserve().await;
462 assert_eq!(b - a, Duration::from_millis(100));
463 assert_eq!(c - b, Duration::from_millis(100));
464 }
465
466 #[tokio::test]
467 async fn rps_caps_request_starts_across_workers() {
468 let mut server = mockito::Server::new_async().await;
469 let _m = server
470 .mock("POST", "/upload")
471 .with_status(200)
472 .expect_at_least(1)
473 .create_async()
474 .await;
475 let cfg = ChunkedBenchConfig {
476 target_url: format!("{}/upload", server.url()),
477 method: reqwest::Method::POST,
478 concurrency: 8,
479 duration: Duration::from_millis(1000),
480 chunk_size_bytes: 64,
481 total_size_bytes: 256,
482 chunk_interval_ms: 0,
483 headers: HashMap::new(),
484 skip_tls_verify: false,
485 rps: Some(5),
486 no_keep_alive: true,
487 body: None,
488 };
489 let r = run(cfg).await.unwrap();
490 assert!((4..=6).contains(&r.total_requests), "got {} requests", r.total_requests);
493 assert_eq!(r.failed, 0);
494 }
495
496 #[tokio::test]
497 async fn rejects_zero_rps() {
498 let cfg = ChunkedBenchConfig {
499 target_url: "http://127.0.0.1:1".into(),
500 method: reqwest::Method::POST,
501 concurrency: 1,
502 duration: Duration::from_millis(10),
503 chunk_size_bytes: 1024,
504 total_size_bytes: 4096,
505 chunk_interval_ms: 0,
506 headers: HashMap::new(),
507 skip_tls_verify: false,
508 rps: Some(0),
509 no_keep_alive: false,
510 body: None,
511 };
512 assert!(run(cfg).await.is_err());
513 }
514
515 #[test]
516 fn json_body_is_valid_and_exact_size() {
517 let seed = serde_json::json!({"name": "widget", "count": 3});
518 for total in [64usize, 4096, 1_048_576] {
519 let body = build_json_body(Some(&seed), total);
520 assert_eq!(body.len(), total);
521 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
522 assert_eq!(v["name"], "widget");
523 assert_eq!(v["count"], 3);
524 }
525 }
526
527 #[test]
528 fn json_body_without_seed_or_non_object_seed() {
529 let body = build_json_body(None, 100);
530 assert_eq!(body.len(), 100);
531 assert!(serde_json::from_slice::<serde_json::Value>(&body).unwrap().is_object());
532
533 let body = build_json_body(Some(&serde_json::json!([1, 2])), 100);
534 assert_eq!(body.len(), 100);
535 let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
536 assert_eq!(v["data"], serde_json::json!([1, 2]));
537 }
538
539 #[test]
540 fn json_body_smaller_than_minimum_stays_valid() {
541 let body = build_json_body(None, 1);
542 assert_eq!(body, br#"{"_padding":""}"#);
543 }
544
545 #[test]
546 fn detects_json_content_types() {
547 let h = |v: &str| HashMap::from([("content-type".to_string(), v.to_string())]);
548 assert!(is_json_content_type(&h("application/json")));
549 assert!(is_json_content_type(&h("Application/JSON; charset=utf-8")));
550 assert!(is_json_content_type(&h("application/vnd.api+json")));
551 assert!(!is_json_content_type(&h("application/octet-stream")));
552 assert!(!is_json_content_type(&HashMap::new()));
553 let upper = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
554 assert!(is_json_content_type(&upper));
555 }
556
557 #[tokio::test]
558 async fn streams_prebuilt_json_body_intact() {
559 let mut server = mockito::Server::new_async().await;
560 let m = server
561 .mock("POST", "/items")
562 .match_body(mockito::Matcher::PartialJson(serde_json::json!({"name": "widget"})))
563 .with_status(201)
564 .expect_at_least(1)
565 .create_async()
566 .await;
567 let body = build_json_body(Some(&serde_json::json!({"name": "widget"})), 10_000);
568 let cfg = ChunkedBenchConfig {
569 target_url: format!("{}/items", server.url()),
570 method: reqwest::Method::POST,
571 concurrency: 1,
572 duration: Duration::from_millis(300),
573 chunk_size_bytes: 777,
574 total_size_bytes: 10_000,
575 chunk_interval_ms: 0,
576 headers: HashMap::from([("Content-Type".into(), "application/json".into())]),
577 skip_tls_verify: false,
578 rps: None,
579 no_keep_alive: false,
580 body: Some(Arc::new(body)),
581 };
582 let r = run(cfg).await.unwrap();
583 assert_eq!(r.failed, 0);
584 assert!(r.status_counts.contains_key(&201), "statuses: {:?}", r.status_counts);
585 assert_eq!(r.bytes_sent, r.successful * 10_000);
586 m.assert_async().await;
587 }
588}