Skip to main content

mockforge_bench/
chunked_bench.rs

1//! Native Rust chunked-encoding traffic generator.
2//!
3//! `mockforge bench --native-chunked` bypasses k6 entirely. Each worker opens
4//! its own HTTP connection and sends POST/PUT/PATCH requests with bodies
5//! streamed via `reqwest::Body::wrap_stream`. Because the body has no known
6//! `Content-Length`, hyper transports it as `Transfer-Encoding: chunked` —
7//! guaranteed, unlike the k6/Go path where the runtime decides based on body
8//! type.
9//!
10//! This is a small benchmark intended to exercise the *server's* chunked
11//! handling (slow consumers, max body size, partial-response chaos against
12//! chunked uploads). Not a k6 replacement for general load testing.
13//!
14//! ```no_run
15//! # use mockforge_bench::chunked_bench::{ChunkedBenchConfig, run};
16//! # use std::time::Duration;
17//! # use std::collections::HashMap;
18//! # async fn x() -> anyhow::Result<()> {
19//! let result = run(ChunkedBenchConfig {
20//!     target_url: "http://localhost:3000/upload".into(),
21//!     method: reqwest::Method::POST,
22//!     concurrency: 10,
23//!     duration: Duration::from_secs(60),
24//!     chunk_size_bytes: 1024,
25//!     total_size_bytes: 1024 * 1024,
26//!     chunk_interval_ms: 0,
27//!     headers: HashMap::new(),
28//!     skip_tls_verify: false,
29//!     rps: None,
30//!     no_keep_alive: false,
31//!     body: None,
32//! }).await?;
33//! println!("{} req/s", result.req_per_sec);
34//! # Ok(()) }
35//! ```
36
37use 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/// Configuration for the native chunked-encoding bench.
50#[derive(Debug, Clone)]
51pub struct ChunkedBenchConfig {
52    /// Target URL (e.g. `http://localhost:3000/upload`).
53    pub target_url: String,
54    /// HTTP method. POST/PUT/PATCH make sense; GET/HEAD don't take a body.
55    pub method: reqwest::Method,
56    /// Number of concurrent workers (each holds its own connection / future).
57    pub concurrency: u32,
58    /// Total run duration.
59    pub duration: Duration,
60    /// Bytes per chunk emitted into the request body stream.
61    pub chunk_size_bytes: usize,
62    /// Total body size per request, in bytes.
63    pub total_size_bytes: usize,
64    /// Sleep between chunks, in milliseconds. 0 = back-to-back.
65    pub chunk_interval_ms: u64,
66    /// Extra headers to attach to every request. `Transfer-Encoding: chunked`
67    /// is set automatically by hyper because the body has no Content-Length.
68    pub headers: HashMap<String, String>,
69    /// Skip TLS certificate verification (useful for test self-signed certs).
70    pub skip_tls_verify: bool,
71    /// Cap on request *starts* per second, shared by all workers. `None` =
72    /// each worker starts its next request as soon as the previous one ends.
73    /// The achieved rate can't exceed `concurrency / request_duration`.
74    pub rps: Option<u32>,
75    /// Open a fresh TCP/TLS connection for every request (no pooling), so the
76    /// connections-per-second rate equals the request rate.
77    pub no_keep_alive: bool,
78    /// Pre-built request body to stream instead of `X` filler (e.g. a JSON
79    /// document from [`build_json_body`]). When set, its length replaces
80    /// `total_size_bytes` as the per-request body size.
81    pub body: Option<Arc<Vec<u8>>>,
82}
83
84impl ChunkedBenchConfig {
85    /// Bytes each request actually sends.
86    fn body_len(&self) -> usize {
87        self.body.as_ref().map_or(self.total_size_bytes, |b| b.len())
88    }
89}
90
91/// Field name used to pad JSON bodies up to the requested size.
92pub const JSON_PADDING_FIELD: &str = "_padding";
93
94/// True when a `Content-Type` header (any casing) names a JSON media type:
95/// `application/json` or any `+json` suffix type.
96pub 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
106/// Build a valid JSON document of exactly `total` bytes.
107///
108/// `seed` (typically the spec-generated request body) supplies the fields; a
109/// string field named [`JSON_PADDING_FIELD`] is added and filled with `X`s so
110/// the serialized size lands on `total`. A non-object seed is wrapped as
111/// `{"data": seed}`. If `total` is smaller than the smallest valid document,
112/// that smallest document is returned (and is longer than `total`).
113pub 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    // `X` serializes as one byte, so the padding length maps 1:1 to output size.
126    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
131/// Hands out evenly spaced request start times across all workers.
132struct 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    /// Reserve the next start slot. Slots are never handed out in the past,
146    /// so a stall doesn't cause a burst to "catch up".
147    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/// One captured non-2xx response — used to surface *who* sent the error
156/// (mockforge? a proxy in front?) and *why*. Critical for diagnosing
157/// "I see 503 from the bench but the server log shows 200" — almost
158/// always an upstream proxy timing out on a slow chunked upload.
159#[derive(Debug, Clone)]
160pub struct ErrorSample {
161    pub status: u16,
162    /// `Server` response header, when present. Often reveals the
163    /// proxy: `nginx/1.21.0`, `cloudflare`, `envoy`, `awselb/2.0`, etc.
164    pub server_header: Option<String>,
165    /// First N bytes of the response body, lossy-UTF8'd. Trimmed.
166    pub body_excerpt: String,
167}
168
169/// Aggregate result from a chunked bench run.
170#[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    /// First N captured non-2xx responses (status, body excerpt, Server
185    /// header). Empty when every request succeeded.
186    pub error_samples: Vec<ErrorSample>,
187}
188
189/// How many distinct error responses to capture body+headers for.
190const MAX_ERROR_SAMPLES: usize = 5;
191/// How many bytes of error response body to keep per sample.
192const ERROR_BODY_EXCERPT_BYTES: usize = 256;
193
194/// Run the chunked-traffic bench. Spawns `concurrency` worker tasks that send
195/// chunked POSTs back-to-back until `duration` elapses, then aggregates stats.
196pub 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
327/// Per-request outcome from `send_one_chunked_request`. Carries an
328/// `ErrorSample` only for non-2xx responses (and only until the caller
329/// has accumulated `MAX_ERROR_SAMPLES`).
330struct 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    // Build a stream that yields fixed-size chunks until `total` bytes are
345    // emitted, slicing the pre-built body when there is one and `X` filler
346    // otherwise. No Content-Length is set on the request, so hyper transports
347    // the body as Transfer-Encoding: chunked.
348    let body_stream = stream! {
349        let mut sent: usize = 0;
350        let filler = vec![b'X'; chunk_size];
351        while sent < total {
352            // Wait *between* chunks: the first chunk goes out immediately.
353            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    // For non-2xx responses, capture a small excerpt + the Server header so
376    // the user can tell at a glance whether the error came from MockForge,
377    // an upstream proxy, a CDN, etc. This is the most useful diagnostic for
378    // the "503 from bench, 200 in TUI" pattern (proxy upstream timeout).
379    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        // Schema sanity: ErrorSample must carry the three pieces a user
407        // needs to diagnose "where did this 503 come from?".
408        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        // 1s at 5 rps = slots at 0, 200, 400, 600, 800ms. Unpaced, 8 workers
491        // against a local server would do hundreds.
492        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}