Skip to main content

tropel_http/
config.rs

1//! HTTP client configuration (P3c): `HttpConfig` and `TlsConfig` moved here
2//! from `tropel-core` so the runtime publish set (and `tropel-http` itself)
3//! stops resolving `tropel-core`. `tropel-core` re-exports these so engine
4//! crates keep resolving `tropel_core::config::*` unchanged.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9use tropel_sdk::config::ExpectedStatus;
10
11/// HTTP client configuration.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct HttpConfig {
15    /// Expected response status codes/ranges that determine request success.
16    /// Used to drive the http_req_failed Rate metric.
17    /// Default: `["200-399"]` — 2xx and 3xx are success, everything else fails.
18    #[serde(default = "default_expected_statuses", alias = "expectedStatuses")]
19    pub expected_statuses: Vec<ExpectedStatus>,
20    /// Connection pool max idle connections.
21    pub max_idle_connections: usize,
22    /// Keep-alive duration.
23    pub keep_alive: Option<String>,
24    /// Timeout for idle connections.
25    pub idle_connection_timeout: Option<String>,
26    /// Global per-request timeout (k6 `timeout`), e.g. `"30s"`. Applied as
27    /// the client-level ceiling for every request; a per-request `timeout`
28    /// overrides it with a shorter value. `None` (default) uses the engine
29    /// default of 10 seconds. Bounds how long a hung server can stall a VU
30    /// (which in turn bounds the engine's VU-drain loop).
31    #[serde(default, alias = "requestTimeout")]
32    pub request_timeout: Option<String>,
33    /// Whether to enable HTTP/2.
34    pub http2: bool,
35    /// Number of HTTP/2 connection lanes (default 1). Each lane is an
36    /// independent reqwest::Client with its own connection pool. VUs are
37    /// assigned to lanes round-robin by vu_id % N. Spreading load across
38    /// N h2 connections hides per-connection server limits and parallelizes
39    /// the single-core frame demux. k6 cannot do this at all.
40    #[serde(default = "default_http2_connections", alias = "http2Connections")]
41    pub http2_connections: usize,
42    /// User-agent header value.
43    pub user_agent: String,
44    /// Whether to decompress response bodies.
45    pub decompress: bool,
46    /// Whether to discard response bodies entirely (don't store bytes).
47    /// Saves memory and bandwidth at the cost of not being able to inspect
48    /// response content in scripts.
49    #[serde(default)]
50    pub discard_response_bodies: bool,
51    /// Max redirects to follow.
52    pub max_redirects: u32,
53    /// Disable redirect following entirely (`--no-redirects`). When true the
54    /// 3xx response is returned as-is and no redirect hops are captured.
55    /// k6 always follows redirects; this flag lets Tropel opt out.
56    #[serde(default)]
57    pub no_redirects: bool,
58    /// Optional fixed ceiling for the latency histogram, in MILLISECONDS.
59    /// `None` (default) uses hdrhistogram auto-resize — no ceiling, so very
60    /// slow requests are recorded exactly instead of being clipped at 60 s.
61    /// Set this to bound memory for runs with pathological outliers.
62    #[serde(default, alias = "histogramMaxMs")]
63    pub histogram_max_ms: Option<u64>,
64    /// DNS cache TTL (k6 `dns.ttl`), e.g. `"5m"`, `"inf"`. `None` (default)
65    /// disables caching — every request resolves. `"0"` also disables it.
66    #[serde(default, alias = "dnsTtl")]
67    pub dns_ttl: Option<String>,
68    /// DNS address selection policy (k6 `dns.select`): `"first"`,
69    /// `"roundRobin"`, `"random"`. `None` (default) keeps all resolved
70    /// addresses in lookup order (reqwest's default behavior).
71    #[serde(default, alias = "dnsSelect")]
72    pub dns_select: Option<String>,
73    /// DNS address policy (k6 `dns.policy`): `"preferIPv4"`, `"preferIPv6"`,
74    /// `"onlyIPv4"`, `"onlyIPv6"`, `"any"`. `None` (default) keeps the
75    /// resolved address family order unchanged.
76    #[serde(default, alias = "dnsPolicy")]
77    pub dns_policy: Option<String>,
78    /// Close the connection after every request (k6 `noConnectionReuse`).
79    /// Disables connection pooling entirely — each request opens a fresh
80    /// connection, which trades latency for isolation.
81    #[serde(default, alias = "noConnectionReuse")]
82    pub no_connection_reuse: bool,
83    /// k6 `noVUConnectionReuse` parity. When true, forces a fresh client
84    /// (own connection pool) per VU. Default false: every VU shares one
85    /// pooled client via Arc clone, keeping connections warm and TLS
86    /// sessions reusable.
87    #[serde(default, alias = "noVUConnectionReuse")]
88    pub no_vu_connection_reuse: bool,
89    /// Global request-rate cap in requests/second (k6 `rps`). When set, the
90    /// whole run is paced so no more than this many requests start per second,
91    /// shared across all VUs. `None` (default) is unlimited.
92    #[serde(default)]
93    pub rps: Option<f64>,
94    /// Static hostname → IP mapping (k6 `hosts`), e.g.
95    /// `{"api.example.com": "127.0.0.1"}`. Lookups for these hosts are served
96    /// from the map without hitting DNS. Values may be comma-separated to
97    /// provide several addresses; keys may be wildcards (`"*.example.com"`).
98    #[serde(default)]
99    pub hosts: HashMap<String, String>,
100    /// IP addresses / CIDRs that requests may never connect to (k6
101    /// `blacklistIPs`), e.g. `["10.0.0.0/8", "192.168.1.5"]`. When every
102    /// resolved address is blacklisted the request fails with a clear error.
103    #[serde(default, alias = "blacklistIPs")]
104    pub blacklist_ips: Vec<String>,
105    /// Hard ceiling on the response body size in BYTES, enforced while the
106    /// body is streamed (final response AND redirect-hop bodies). `None`
107    /// (default) is unlimited — k6 semantics. Proxy-style consumers
108    /// (KnockPort relay) set this so a runaway upstream can't fill memory.
109    #[serde(default, alias = "maxResponseBytes")]
110    pub max_response_bytes: Option<u64>,
111    /// Log every HTTP request/response at debug level (method, URL, status,
112    /// timing). Off by default; enable with the `--http-debug` CLI flag.
113    /// k6's `--http-debug=full` also prints request/response bodies — that
114    /// extra mode is `http_debug_full`.
115    #[serde(default)]
116    /// Proxy configuration (ask 17). `ProxyMode::Off` by default, so every
117    /// existing config behaves exactly as before. The container's own
118    /// `#[serde(default)]` covers an absent key.
119    pub proxy: crate::proxy::ProxyConfig,
120    pub http_debug: bool,
121    /// `--http-debug=full` (k6 parity): also print the request/response
122    /// bodies, not just the head lines. Only meaningful with `http_debug`.
123    #[serde(default)]
124    pub http_debug_full: bool,
125}
126
127fn default_expected_statuses() -> Vec<ExpectedStatus> {
128    vec![ExpectedStatus::Range("200-399".to_string())]
129}
130
131fn default_http2_connections() -> usize {
132    1
133}
134
135impl Default for HttpConfig {
136    fn default() -> Self {
137        Self {
138            // 2xx-3xx = success (default, matches k6 behavior)
139            expected_statuses: default_expected_statuses(),
140            // The HTTP client is shared across all VUs (Arc), so this caps
141            // idle connections per host for the ENTIRE run, not per VU.
142            // reqwest's own default is usize::MAX (unlimited); k6 gives 6 per
143            // VU so 100 VUs = 600 idle connections per host. Using reqwest's
144            // default matches that scaling behavior.
145            max_idle_connections: usize::MAX,
146            keep_alive: Some("30s".to_string()),
147            // How long an idle connection is kept before being closed.
148            idle_connection_timeout: Some("30s".to_string()),
149            request_timeout: None,
150            http2: true,
151            http2_connections: 1,
152            user_agent: "Tropel/0.1.0".to_string(),
153            decompress: true,
154            max_redirects: 10,
155            no_redirects: false,
156            discard_response_bodies: false,
157            histogram_max_ms: None,
158            dns_ttl: None,
159            dns_select: None,
160            dns_policy: None,
161            no_connection_reuse: false,
162            no_vu_connection_reuse: false,
163            rps: None,
164            hosts: HashMap::new(),
165            blacklist_ips: Vec::new(),
166            max_response_bytes: None,
167            proxy: crate::proxy::ProxyConfig::default(),
168            http_debug: false,
169            http_debug_full: false,
170        }
171    }
172}
173
174/// TLS configuration.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(default)]
177pub struct TlsConfig {
178    pub insecure_skip_verify: bool,
179    pub min_version: Option<String>,
180    pub max_version: Option<String>,
181    pub client_cert: Option<String>,
182    pub client_key: Option<String>,
183    pub client_passphrase: Option<String>,
184    pub allowed_ciphers: Vec<String>,
185    /// PEM CA bundles to TRUST, in addition to the platform roots.
186    ///
187    /// A list, not one path, because a private CA is commonly a chain split
188    /// across files and because two independent CAs (a corporate root and a
189    /// test root) is the ordinary case. Each entry is read and added
190    /// separately, so one unreadable bundle names itself in the error rather
191    /// than failing the whole set anonymously.
192    #[serde(default, alias = "rootCertPaths")]
193    pub root_cert_paths: Vec<String>,
194    /// Keep the platform verifier's roots alongside `root_cert_paths`.
195    ///
196    /// TRUE by default, and that default is the important part: adding a
197    /// private CA is nearly always ADDITIVE — you still need to reach
198    /// github.com. Defaulting to false would make a config that adds one
199    /// internal root silently stop trusting the public internet, which
200    /// presents as "everything broke after I added our CA".
201    ///
202    /// Set false deliberately to pin: only the supplied bundles are trusted,
203    /// which is what a locked-down test environment wants.
204    #[serde(default = "default_true", alias = "keepSystemRoots")]
205    pub keep_system_roots: bool,
206}
207
208fn default_true() -> bool {
209    true
210}
211
212impl Default for TlsConfig {
213    fn default() -> Self {
214        // Hand-written rather than derived because `keep_system_roots` must
215        // default TRUE and `#[derive(Default)]` would give false — the one
216        // field here whose zero value is the wrong answer.
217        Self {
218            insecure_skip_verify: false,
219            min_version: None,
220            max_version: None,
221            client_cert: None,
222            client_key: None,
223            client_passphrase: None,
224            allowed_ciphers: Vec::new(),
225            root_cert_paths: Vec::new(),
226            keep_system_roots: true,
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn http_config_defaults_match_k6() {
237        let cfg = HttpConfig::default();
238        // Default expected list: 2xx + 3xx succeed, 4xx/5xx fail.
239        assert!(cfg.expected_statuses.iter().any(|e| e.matches(200)));
240        assert!(cfg.expected_statuses.iter().any(|e| e.matches(304)));
241        assert!(!cfg.expected_statuses.iter().any(|e| e.matches(404)));
242        assert_eq!(cfg.max_redirects, 10);
243        assert!(cfg.http2);
244        assert_eq!(cfg.http2_connections, 1);
245    }
246
247    #[test]
248    fn http2_connections_camel_case_alias() {
249        let json = r#"{"http2Connections": 4}"#;
250        let cfg: HttpConfig = serde_json::from_str(json).unwrap();
251        assert_eq!(cfg.http2_connections, 4);
252    }
253}