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}