httpress/config.rs
1//! Configuration types and contexts for benchmarks.
2//!
3//! This module contains types used to configure benchmarks and pass context
4//! to hooks and generator functions:
5//!
6//! - **Configuration**: [`HttpMethod`], [`RequestConfig`], [`RequestSource`]
7//! - **Generator Contexts**: [`RequestContext`], [`RateContext`]
8//! - **Hook Contexts**: [`BeforeRequestContext`], [`AfterRequestContext`]
9//! - **Hook Control**: [`HookAction`]
10//! - **Function Types**: [`RequestGenerator`], [`RateFunction`], [`BeforeRequestHook`], [`AfterRequestHook`]
11//!
12//! # Examples
13//!
14//! Using request context for dynamic URLs:
15//! ```no_run
16//! use httpress::{Benchmark, RequestContext, RequestConfig, HttpMethod};
17//! use std::collections::HashMap;
18//!
19//! # #[tokio::main]
20//! # async fn main() -> httpress::Result<()> {
21//! Benchmark::builder()
22//! .request_fn(|ctx: RequestContext| {
23//! RequestConfig {
24//! url: format!("http://localhost:3000/user/{}", ctx.request_number),
25//! method: HttpMethod::Get,
26//! headers: HashMap::new(),
27//! body: None,
28//! }
29//! })
30//! .requests(100)
31//! .build()?;
32//! # Ok(())
33//! # }
34//! ```
35
36use std::collections::HashMap;
37use std::sync::Arc;
38use std::time::Duration;
39
40use bytes::Bytes;
41
42use indicatif::ProgressBar;
43
44use crate::cli::Args;
45use crate::error::Error;
46use crate::progress::{ProgressFn, create_progress_bar, update_progress_bar};
47
48/// Defines when the benchmark should stop
49#[derive(Debug, Clone)]
50pub enum StopCondition {
51 /// Stop after N requests
52 Requests(usize),
53 /// Stop after duration
54 Duration(Duration),
55 /// Run until interrupted (Ctrl+C)
56 Infinite,
57}
58
59/// HTTP method for requests.
60///
61/// Specifies the HTTP method to use when making requests to the target server.
62///
63/// # Examples
64///
65/// ```
66/// use httpress::HttpMethod;
67///
68/// let method = HttpMethod::Post;
69/// ```
70#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
71pub enum HttpMethod {
72 /// HTTP GET method.
73 Get,
74 /// HTTP POST method.
75 Post,
76 /// HTTP PUT method.
77 Put,
78 /// HTTP DELETE method.
79 Delete,
80 /// HTTP PATCH method.
81 Patch,
82 /// HTTP HEAD method.
83 Head,
84 /// HTTP OPTIONS method.
85 Options,
86}
87
88/// Configuration for a single HTTP request.
89///
90/// Used by custom request generator functions to specify the details of each request.
91/// When using [`BenchmarkBuilder::request_fn`](crate::BenchmarkBuilder::request_fn),
92/// your function returns this struct to configure each individual request.
93///
94/// # Examples
95///
96/// ```
97/// use httpress::{RequestConfig, HttpMethod};
98/// use std::collections::HashMap;
99/// use bytes::Bytes;
100///
101/// let config = RequestConfig {
102/// url: "http://localhost:3000/api/users".to_string(),
103/// method: HttpMethod::Post,
104/// headers: HashMap::from([
105/// ("Content-Type".to_string(), "application/json".to_string()),
106/// ("Authorization".to_string(), "Bearer token123".to_string()),
107/// ]),
108/// body: Some(Bytes::from(r#"{"name": "John"}"#)),
109/// };
110/// ```
111#[derive(Debug, Clone)]
112pub struct RequestConfig {
113 /// The target URL for this request.
114 pub url: String,
115
116 /// The HTTP method to use.
117 pub method: HttpMethod,
118
119 /// HTTP headers to include in the request.
120 pub headers: HashMap<String, String>,
121
122 /// Optional request body.
123 pub body: Option<Bytes>,
124}
125
126/// Context passed to request generator functions.
127///
128/// Provides information about the current request context, allowing you to generate
129/// different requests based on worker ID and request number.
130///
131/// # Examples
132///
133/// ```no_run
134/// # use httpress::{Benchmark, RequestContext, RequestConfig, HttpMethod};
135/// # use std::collections::HashMap;
136/// # #[tokio::main]
137/// # async fn main() -> httpress::Result<()> {
138/// Benchmark::builder()
139/// .request_fn(|ctx: RequestContext| {
140/// // Rotate through 100 different user IDs
141/// let user_id = ctx.request_number % 100;
142///
143/// // Add worker ID to headers for debugging
144/// let mut headers = HashMap::new();
145/// headers.insert("X-Worker-Id".to_string(), ctx.worker_id.to_string());
146///
147/// RequestConfig {
148/// url: format!("http://localhost:3000/user/{}", user_id),
149/// method: HttpMethod::Get,
150/// headers,
151/// body: None,
152/// }
153/// })
154/// .concurrency(10)
155/// .requests(1000)
156/// .build()?;
157/// # Ok(())
158/// # }
159/// ```
160#[derive(Debug, Clone, Copy)]
161pub struct RequestContext {
162 /// ID of the worker executing this request (0-based).
163 ///
164 /// Each concurrent worker has a unique ID from 0 to (concurrency - 1).
165 pub worker_id: usize,
166
167 /// Sequential request number for this worker (0-based).
168 ///
169 /// This increments for each request made by this specific worker.
170 pub request_number: usize,
171}
172
173/// Context passed to rate generator functions.
174///
175/// Provides runtime information about the benchmark state, allowing you to dynamically
176/// adjust the request rate based on elapsed time, request counts, or success rates.
177///
178/// # Examples
179///
180/// ```no_run
181/// # use httpress::{Benchmark, RateContext};
182/// # use std::time::Duration;
183/// # #[tokio::main]
184/// # async fn main() -> httpress::Result<()> {
185/// Benchmark::builder()
186/// .url("http://localhost:3000")
187/// .rate_fn(|ctx: RateContext| {
188/// let elapsed_secs = ctx.elapsed.as_secs_f64();
189///
190/// // Linear ramp from 100 to 1000 req/s over 10 seconds
191/// if elapsed_secs < 10.0 {
192/// let progress = elapsed_secs / 10.0;
193/// 100.0 + (900.0 * progress)
194/// } else {
195/// 1000.0
196/// }
197/// })
198/// .duration(Duration::from_secs(30))
199/// .build()?;
200/// # Ok(())
201/// # }
202/// ```
203#[derive(Debug, Clone, Copy)]
204pub struct RateContext {
205 /// Time elapsed since benchmark start.
206 pub elapsed: Duration,
207
208 /// Total requests completed so far (success + failure).
209 pub total_requests: usize,
210
211 /// Successful requests so far (HTTP status 2xx).
212 pub successful_requests: usize,
213
214 /// Failed requests so far (non-2xx status or connection errors).
215 pub failed_requests: usize,
216
217 /// Current configured rate in requests per second (for reference).
218 ///
219 /// This reflects the rate returned by the previous call to the rate function.
220 pub current_rate: f64,
221}
222
223/// Type alias for request generator functions.
224///
225/// A request generator is a function that creates a [`RequestConfig`] for each request
226/// based on the provided [`RequestContext`]. This allows you to dynamically generate
227/// requests with different URLs, methods, headers, or bodies.
228///
229/// # Type Signature
230///
231/// ```text
232/// Fn(RequestContext) -> RequestConfig + Send + Sync + 'static
233/// ```
234///
235/// # Examples
236///
237/// See [`BenchmarkBuilder::request_fn`](crate::BenchmarkBuilder::request_fn) for usage examples.
238pub type RequestGenerator = Arc<dyn Fn(RequestContext) -> RequestConfig + Send + Sync>;
239
240/// Type alias for rate generator functions.
241///
242/// A rate function dynamically determines the request rate (requests per second) based
243/// on the current benchmark state provided in [`RateContext`]. This enables advanced
244/// patterns like rate ramping, adaptive rate control, or scheduled rate changes.
245///
246/// # Type Signature
247///
248/// ```text
249/// Fn(RateContext) -> f64 + Send + Sync + 'static
250/// ```
251///
252/// The returned `f64` value represents the desired requests per second.
253///
254/// # Examples
255///
256/// See [`BenchmarkBuilder::rate_fn`](crate::BenchmarkBuilder::rate_fn) for usage examples.
257pub type RateFunction = Arc<dyn Fn(RateContext) -> f64 + Send + Sync>;
258
259/// Context passed to before-request hook functions.
260///
261/// Provides information about the benchmark state before a request is sent.
262/// Before-request hooks can use this to implement rate limiting, circuit breakers,
263/// or conditional request execution.
264///
265/// # Examples
266///
267/// ```no_run
268/// # use httpress::{Benchmark, BeforeRequestContext, HookAction};
269/// # #[tokio::main]
270/// # async fn main() -> httpress::Result<()> {
271/// Benchmark::builder()
272/// .url("http://localhost:3000")
273/// .before_request(|ctx: BeforeRequestContext| {
274/// // Circuit breaker: stop sending requests if too many failures
275/// let failure_rate = ctx.failed_requests as f64 / ctx.total_requests.max(1) as f64;
276/// if failure_rate > 0.5 && ctx.total_requests > 100 {
277/// println!("Circuit breaker triggered at {}% failures", failure_rate * 100.0);
278/// HookAction::Abort
279/// } else {
280/// HookAction::Continue
281/// }
282/// })
283/// .requests(1000)
284/// .build()?;
285/// # Ok(())
286/// # }
287/// ```
288#[derive(Debug, Clone, Copy)]
289pub struct BeforeRequestContext {
290 /// ID of the worker executing this request (0-based).
291 pub worker_id: usize,
292
293 /// Sequential request number for this worker (0-based).
294 pub request_number: usize,
295
296 /// Time elapsed since benchmark start.
297 pub elapsed: Duration,
298
299 /// Total requests completed so far (success + failure).
300 pub total_requests: usize,
301
302 /// Successful requests so far (HTTP status 2xx).
303 pub successful_requests: usize,
304
305 /// Failed requests so far (non-2xx status or connection errors).
306 pub failed_requests: usize,
307}
308
309/// Context passed to after-request hook functions.
310///
311/// Provides detailed information about a completed request, including latency and status code.
312/// After-request hooks can use this for custom metrics collection, retry logic based on
313/// response status, or conditional behavior based on performance.
314///
315/// # Examples
316///
317/// ```no_run
318/// # use httpress::{Benchmark, AfterRequestContext, HookAction};
319/// # use std::sync::{Arc, Mutex};
320/// # use std::time::Duration;
321/// # #[tokio::main]
322/// # async fn main() -> httpress::Result<()> {
323/// let slow_request_count = Arc::new(Mutex::new(0));
324/// let slow_count_clone = slow_request_count.clone();
325///
326/// Benchmark::builder()
327/// .url("http://localhost:3000")
328/// .after_request(move |ctx: AfterRequestContext| {
329/// // Track slow requests (> 100ms)
330/// if ctx.latency > Duration::from_millis(100) {
331/// let mut count = slow_count_clone.lock().unwrap();
332/// *count += 1;
333/// }
334///
335/// // Retry on 5xx errors
336/// if let Some(status) = ctx.status {
337/// if status >= 500 {
338/// return HookAction::Retry;
339/// }
340/// }
341///
342/// HookAction::Continue
343/// })
344/// .max_retries(3)
345/// .requests(1000)
346/// .build()?;
347/// # Ok(())
348/// # }
349/// ```
350#[derive(Debug, Clone, Copy)]
351pub struct AfterRequestContext {
352 /// ID of the worker that executed this request (0-based).
353 pub worker_id: usize,
354
355 /// Sequential request number for this worker (0-based).
356 pub request_number: usize,
357
358 /// Time elapsed since benchmark start.
359 pub elapsed: Duration,
360
361 /// Total requests completed so far (success + failure).
362 pub total_requests: usize,
363
364 /// Successful requests so far (HTTP status 2xx).
365 pub successful_requests: usize,
366
367 /// Failed requests so far (non-2xx status or connection errors).
368 pub failed_requests: usize,
369
370 /// Time taken for this request (latency).
371 pub latency: Duration,
372
373 /// HTTP status code if the request succeeded, `None` if it failed.
374 pub status: Option<u16>,
375}
376
377/// Action returned by hook functions to control request execution.
378///
379/// Hook functions (both before-request and after-request) return this enum to signal
380/// what action the benchmark executor should take for the current request.
381///
382/// # Examples
383///
384/// ```no_run
385/// # use httpress::{Benchmark, AfterRequestContext, HookAction};
386/// # #[tokio::main]
387/// # async fn main() -> httpress::Result<()> {
388/// Benchmark::builder()
389/// .url("http://localhost:3000")
390/// .after_request(|ctx: AfterRequestContext| {
391/// match ctx.status {
392/// Some(status) if status >= 500 => {
393/// // Retry server errors
394/// HookAction::Retry
395/// }
396/// Some(status) if status == 429 => {
397/// // Abort on rate limiting
398/// HookAction::Abort
399/// }
400/// _ => {
401/// // Continue normally
402/// HookAction::Continue
403/// }
404/// }
405/// })
406/// .max_retries(3)
407/// .requests(1000)
408/// .build()?;
409/// # Ok(())
410/// # }
411/// ```
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub enum HookAction {
414 /// Continue with normal execution.
415 ///
416 /// The request proceeds normally. This is the typical return value.
417 Continue,
418
419 /// Abort this request without retrying.
420 ///
421 /// The request is counted as failed, but the benchmark continues with other requests.
422 /// Use this for requests that should be skipped (e.g., circuit breaker triggered).
423 Abort,
424
425 /// Retry this request.
426 ///
427 /// The request will be retried up to the configured `max_retries` limit.
428 /// Use this for transient errors that might succeed on retry (e.g., 5xx errors).
429 Retry,
430}
431
432/// Type alias for before-request hook functions.
433///
434/// Before-request hooks are called before sending each HTTP request. They receive
435/// [`BeforeRequestContext`] and return [`HookAction`] to control execution flow.
436///
437/// # Type Signature
438///
439/// ```text
440/// Fn(BeforeRequestContext) -> HookAction + Send + Sync + 'static
441/// ```
442///
443/// # Examples
444///
445/// See [`BenchmarkBuilder::before_request`](crate::BenchmarkBuilder::before_request) for usage examples.
446pub type BeforeRequestHook = Arc<dyn Fn(BeforeRequestContext) -> HookAction + Send + Sync>;
447
448/// Type alias for after-request hook functions.
449///
450/// After-request hooks are called after each HTTP request completes (or fails).
451/// They receive [`AfterRequestContext`] with request latency and status code,
452/// and return [`HookAction`] to control execution flow.
453///
454/// # Type Signature
455///
456/// ```text
457/// Fn(AfterRequestContext) -> HookAction + Send + Sync + 'static
458/// ```
459///
460/// # Examples
461///
462/// See [`BenchmarkBuilder::after_request`](crate::BenchmarkBuilder::after_request) for usage examples.
463pub type AfterRequestHook = Arc<dyn Fn(AfterRequestContext) -> HookAction + Send + Sync>;
464
465/// Source of request configuration - either static or dynamically generated.
466///
467/// This enum represents how requests are configured in a benchmark. It is used
468/// internally by the builder and executor, but is exposed publicly as part of
469/// the configuration API.
470///
471/// You typically don't construct this directly; instead use
472/// [`BenchmarkBuilder::url`](crate::BenchmarkBuilder::url) for static configuration or
473/// [`BenchmarkBuilder::request_fn`](crate::BenchmarkBuilder::request_fn) for dynamic generation.
474#[derive(Clone)]
475pub enum RequestSource {
476 /// Static configuration used for all requests.
477 ///
478 /// Created when using [`BenchmarkBuilder::url`](crate::BenchmarkBuilder::url).
479 Static(RequestConfig),
480
481 /// Dynamic generator function called for each request.
482 ///
483 /// Created when using [`BenchmarkBuilder::request_fn`](crate::BenchmarkBuilder::request_fn).
484 Dynamic(RequestGenerator),
485}
486
487/// Benchmark configuration
488#[derive(Clone)]
489pub struct BenchConfig {
490 pub request_source: RequestSource,
491 pub concurrency: usize,
492 pub stop_condition: StopCondition,
493 pub timeout: Duration,
494 pub rate: Option<u64>,
495 pub rate_fn: Option<RateFunction>,
496 pub before_request_hooks: Vec<BeforeRequestHook>,
497 pub after_request_hooks: Vec<AfterRequestHook>,
498 pub max_retries: usize,
499 pub progress_fn: Option<ProgressFn>,
500}
501
502impl BenchConfig {
503 /// Create config from CLI arguments
504 pub fn from_args(args: Args) -> Result<Self, Error> {
505 let stop_condition = match (args.requests, args.duration) {
506 (Some(n), None) => StopCondition::Requests(n),
507 (None, Some(d)) => StopCondition::Duration(parse_duration(&d)?),
508 (None, None) => StopCondition::Infinite,
509 (Some(_), Some(_)) => unreachable!("clap prevents this"),
510 };
511
512 let headers = parse_headers(&args.headers)?;
513
514 let request_config = RequestConfig {
515 url: args.url,
516 method: args.method,
517 headers,
518 body: args.body.map(Bytes::from),
519 };
520
521 Ok(BenchConfig {
522 request_source: RequestSource::Static(request_config),
523 concurrency: args.concurrency,
524 stop_condition,
525 timeout: Duration::from_secs(args.timeout),
526 rate: args.rate,
527 rate_fn: None,
528 before_request_hooks: Vec::new(),
529 after_request_hooks: Vec::new(),
530 max_retries: 3,
531 progress_fn: None,
532 })
533 }
534
535 /// Attach a built-in terminal progress bar and return the updated config
536 /// alongside the bar handle (used to call `finish_and_clear` after the run).
537 pub fn with_progress(mut self) -> (Self, Arc<ProgressBar>) {
538 let pb = Arc::new(create_progress_bar(&self.stop_condition));
539 let pb_fn = Arc::clone(&pb);
540 self.progress_fn = Some(Arc::new(move |snap| update_progress_bar(&pb_fn, &snap)));
541 (self, pb)
542 }
543}
544
545/// Parse duration string like "10s", "1m", "500ms"
546fn parse_duration(s: &str) -> Result<Duration, Error> {
547 let s = s.trim();
548
549 if let Some(ms) = s.strip_suffix("ms") {
550 let ms: u64 = ms
551 .parse()
552 .map_err(|_| Error::InvalidDuration(s.to_string()))?;
553 return Ok(Duration::from_millis(ms));
554 }
555
556 if let Some(secs) = s.strip_suffix('s') {
557 let secs: u64 = secs
558 .parse()
559 .map_err(|_| Error::InvalidDuration(s.to_string()))?;
560 return Ok(Duration::from_secs(secs));
561 }
562
563 if let Some(mins) = s.strip_suffix('m') {
564 let mins: u64 = mins
565 .parse()
566 .map_err(|_| Error::InvalidDuration(s.to_string()))?;
567 return Ok(Duration::from_secs(mins * 60));
568 }
569
570 // Try parsing as plain seconds
571 if let Ok(secs) = s.parse::<u64>() {
572 return Ok(Duration::from_secs(secs));
573 }
574
575 Err(Error::InvalidDuration(s.to_string()))
576}
577
578/// Parse header strings like "Content-Type: application/json"
579fn parse_headers(headers: &[String]) -> Result<HashMap<String, String>, Error> {
580 let mut map = HashMap::new();
581
582 for h in headers {
583 let (key, value) = h
584 .split_once(':')
585 .ok_or_else(|| Error::InvalidHeader(h.clone()))?;
586
587 map.insert(key.trim().to_string(), value.trim().to_string());
588 }
589
590 Ok(map)
591}