lmn_core/http/request.rs
1use std::sync::Arc;
2use std::time::Duration;
3use std::time::Instant;
4
5use crate::command::{Body, HttpMethod};
6use crate::config::secret::SensitiveString;
7use crate::response_template::field::TrackedField;
8
9/// Maximum response body size read into memory for capture extraction or
10/// response template processing. Prevents OOM when a target server returns
11/// unexpectedly large responses (e.g. 100 VUs × 100 MB = 10 GB).
12const MAX_RESPONSE_BODY_SIZE: usize = 10 * 1024 * 1024; // 10 MiB
13
14// ── Body format ───────────────────────────────────────────────────────────────
15
16pub enum BodyFormat {
17 Json,
18}
19
20// ── Shared per-run config ─────────────────────────────────────────────────────
21
22pub struct RequestConfig {
23 pub client: reqwest::Client,
24 pub host: Arc<String>,
25 pub method: HttpMethod,
26 pub body: Arc<Option<Body>>,
27 pub tracked_fields: Option<Arc<Vec<TrackedField>>>,
28 /// Static headers applied to every request in the run.
29 /// Stored as `Arc` so the list is shared cheaply across concurrent tasks.
30 /// Values are `SensitiveString` so secrets are redacted in debug output.
31 pub headers: Arc<Vec<(String, SensitiveString)>>,
32}
33
34impl RequestConfig {
35 /// Resolves the body for a single request.
36 /// Returns `(content, content_type)`, or `None` if no body is configured.
37 /// `template_body` takes priority when provided (pre-generated template output).
38 pub fn resolve_body(&self, template_body: Option<String>) -> Option<(String, &'static str)> {
39 if let Some(content) = template_body {
40 return Some((content, "application/json"));
41 }
42 self.body.as_ref().as_ref().map(|b| match b {
43 Body::Formatted { content, format } => (
44 content.clone(),
45 match format {
46 BodyFormat::Json => "application/json",
47 },
48 ),
49 })
50 }
51}
52
53// ── Result ────────────────────────────────────────────────────────────────────
54
55pub struct RequestResult {
56 pub duration: Duration,
57 /// Wall-clock instant at which the response was received (or the error occurred).
58 /// Used by the output module to bucket results into per-stage windows when
59 /// `mode == Curve`. Zero-cost in fixed mode where `curve_stages` is `None`.
60 pub completed_at: Instant,
61 pub success: bool,
62 pub status_code: Option<u16>,
63 pub response_body: Option<String>,
64}
65
66// ── RequestRecord ─────────────────────────────────────────────────────────────
67
68/// Lightweight per-request record sent from VU to coordinator over the channel.
69///
70/// Unlike `RequestResult`, carries no raw response body — any extraction is done
71/// inside the VU before sending, keeping KB-sized bodies off the channel.
72pub struct RequestRecord {
73 pub duration: std::time::Duration,
74 /// Wall-clock instant at which the response was received (or the error occurred).
75 /// Used by the drain task to bucket results into per-stage windows in curve mode.
76 pub completed_at: Instant,
77 pub success: bool,
78 pub status_code: Option<u16>,
79 /// Present only when a response template is active and extraction succeeded.
80 pub extraction: Option<crate::response_template::extractor::ExtractionResult>,
81 /// Optional scenario name associated with this request.
82 pub scenario: Option<Arc<str>>,
83 /// Optional step name inside a scenario.
84 pub step: Option<Arc<str>>,
85 /// True when this record represents a step that was skipped (not executed).
86 /// Skipped records count toward `total_requests` but do not contribute to
87 /// latency histograms or status code distributions.
88 pub skipped: bool,
89}
90
91impl RequestResult {
92 /// Constructs a `RequestResult` with all fields explicit.
93 ///
94 /// Use this constructor rather than struct literals so that adding fields in the
95 /// future causes a compile error at every call site, preventing silent omissions.
96 pub fn new(
97 duration: Duration,
98 success: bool,
99 status_code: Option<u16>,
100 response_body: Option<String>,
101 ) -> Self {
102 Self {
103 duration,
104 completed_at: Instant::now(),
105 success,
106 status_code,
107 response_body,
108 }
109 }
110}
111
112// ── Request builder ───────────────────────────────────────────────────────────
113
114pub struct Request {
115 client: reqwest::Client,
116 url: Arc<String>,
117 method: HttpMethod,
118 body: Option<(String, &'static str)>,
119 headers: Option<Arc<Vec<(String, String)>>>,
120 capture_response: bool,
121}
122
123impl Request {
124 pub fn new(client: reqwest::Client, url: Arc<String>, method: HttpMethod) -> Self {
125 Self {
126 client,
127 url,
128 method,
129 body: None,
130 headers: None,
131 capture_response: false,
132 }
133 }
134
135 pub fn body(mut self, content: String, content_type: &'static str) -> Self {
136 self.body = Some((content, content_type));
137 self
138 }
139
140 /// Attach a list of custom HTTP headers.
141 /// These are applied after the auto-set `Content-Type`, so a user-supplied
142 /// `Content-Type` header will override the auto-set one.
143 pub fn headers(mut self, headers: Arc<Vec<(String, String)>>) -> Self {
144 self.headers = Some(headers);
145 self
146 }
147
148 pub fn read_response(mut self) -> Self {
149 self.capture_response = true;
150 self
151 }
152
153 pub async fn execute(self) -> RequestResult {
154 let start = Instant::now();
155 let mut req = match self.method {
156 HttpMethod::Get => self.client.get(self.url.as_str()),
157 HttpMethod::Post => self.client.post(self.url.as_str()),
158 HttpMethod::Put => self.client.put(self.url.as_str()),
159 HttpMethod::Patch => self.client.patch(self.url.as_str()),
160 HttpMethod::Delete => self.client.delete(self.url.as_str()),
161 };
162 if let Some((content, content_type)) = self.body {
163 req = req.header("Content-Type", content_type).body(content);
164 }
165 // Apply user-supplied headers AFTER body/Content-Type so they take precedence.
166 if let Some(headers) = self.headers {
167 for (name, value) in headers.iter() {
168 req = req.header(name, value);
169 }
170 }
171
172 match req.send().await {
173 Ok(resp) => {
174 let status = resp.status();
175 let response_body = if self.capture_response {
176 // Pre-check content-length when available to reject oversized
177 // responses before buffering. Chunked responses without
178 // content-length fall through to the post-read check.
179 let too_large = resp
180 .content_length()
181 .is_some_and(|len| len > MAX_RESPONSE_BODY_SIZE as u64);
182 if too_large {
183 None
184 } else {
185 match resp.bytes().await {
186 Ok(bytes) if bytes.len() <= MAX_RESPONSE_BODY_SIZE => {
187 String::from_utf8(bytes.to_vec()).ok()
188 }
189 _ => None,
190 }
191 }
192 } else {
193 None
194 };
195 let duration = start.elapsed();
196 let completed_at = Instant::now();
197 RequestResult {
198 duration,
199 completed_at,
200 success: status.is_success(),
201 status_code: Some(status.as_u16()),
202 response_body,
203 }
204 }
205 Err(_) => {
206 let duration = start.elapsed();
207 let completed_at = Instant::now();
208 RequestResult {
209 duration,
210 completed_at,
211 success: false,
212 status_code: None,
213 response_body: None,
214 }
215 }
216 }
217 }
218}