1use std::collections::HashMap;
8use std::fmt;
9use std::time::Duration;
10
11use futures_util::future::BoxFuture;
12
13use microvms_app::error::{Error, WireKind};
14
15const RESPONSE_LIMIT: usize = 512 << 20;
18const ERROR_LIMIT: usize = 64 << 10;
19use microvms_app::session::http::{
20 ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream,
21};
22
23pub struct ReqwestBackend {
29 client: reqwest::Client,
30 base_url: String,
31 timeout: Duration,
32}
33
34impl ReqwestBackend {
35 pub fn new(base_url: &str, timeout: Duration) -> Result<Self, Error> {
41 let client = reqwest::Client::builder()
42 .timeout(timeout)
43 .redirect(reqwest::redirect::Policy::none())
45 .build()
46 .map_err(|err| {
47 Error::new(
48 microvms_app::error::ErrorKind::Unexpected,
49 format!("could not build an HTTP client: {err}"),
50 )
51 .with_source(err)
52 })?;
53 Ok(Self {
54 client,
55 base_url: normalize_base_url(base_url),
56 timeout,
57 })
58 }
59
60 fn url(&self, path: &str) -> String {
61 format!("{}{path}", self.base_url)
62 }
63
64 fn build(&self, request: &HttpRequest) -> reqwest::RequestBuilder {
65 let mut builder = self
66 .client
67 .request(
68 reqwest::Method::from_bytes(request.method.as_bytes())
69 .unwrap_or(reqwest::Method::GET),
70 self.url(&request.path),
71 )
72 .timeout(request.timeout.unwrap_or(self.timeout));
73 for (name, value) in &request.headers {
74 builder = builder.header(name, value);
75 }
76 if !request.body.is_empty() {
77 builder = builder.body(request.body.clone());
78 }
79 builder
80 }
81}
82
83fn normalize_base_url(base_url: &str) -> String {
86 let with_scheme = if base_url.starts_with("http://") || base_url.starts_with("https://") {
87 base_url.to_string()
88 } else {
89 format!("https://{base_url}")
90 };
91 with_scheme.trim_end_matches('/').to_string()
92}
93
94fn transport_error(method: &str, path: &str, err: reqwest::Error) -> Error {
96 Error::wire(
97 WireKind::Transport,
98 format!("{method} {path} failed on the wire: {err}"),
99 )
100 .with_source(err)
101}
102
103fn collect_headers(response: &reqwest::Response) -> HashMap<String, String> {
104 response
105 .headers()
106 .iter()
107 .map(|(name, value)| {
108 (
109 name.as_str().to_string(),
110 String::from_utf8_lossy(value.as_bytes()).into_owned(),
111 )
112 })
113 .collect()
114}
115
116impl HttpBackend for ReqwestBackend {
117 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
118 Box::pin(async move {
119 let response = self
120 .build(&request)
121 .send()
122 .await
123 .map_err(|err| transport_error(request.method, &request.path, err))?;
124 let status = response.status().as_u16();
125 let headers = collect_headers(&response);
126 let limit = if is_failure(status) {
127 ERROR_LIMIT
128 } else {
129 RESPONSE_LIMIT
130 };
131 let body = collect_body(response, &request, limit, self.timeout).await?;
132 Ok(HttpResponse {
133 status,
134 headers,
135 body,
136 })
137 })
138 }
139
140 fn open_stream(
141 &self,
142 request: HttpRequest,
143 idle_timeout: Duration,
144 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
145 Box::pin(async move {
146 let response = self
147 .build(&request)
148 .timeout(Duration::MAX)
152 .send()
153 .await
154 .map_err(|err| transport_error(request.method, &request.path, err))?;
155 let status = response.status().as_u16();
156 let headers = collect_headers(&response);
157
158 if is_failure(status) {
161 let body = collect_body(response, &request, ERROR_LIMIT, idle_timeout).await?;
162 return Ok((
163 HttpResponse {
164 status,
165 headers,
166 body,
167 },
168 Box::new(EmptyChunks) as Box<dyn ChunkSource>,
169 ));
170 }
171
172 let head = HttpResponse {
173 status,
174 headers,
175 body: Vec::new(),
176 };
177 let chunks = ReqwestChunks {
178 response: Some(response),
179 idle_timeout,
180 method: request.method,
181 path: request.path,
182 };
183 Ok((head, Box::new(chunks) as Box<dyn ChunkSource>))
184 })
185 }
186}
187
188async fn collect_body(
190 mut response: reqwest::Response,
191 request: &HttpRequest,
192 limit: usize,
193 idle_timeout: Duration,
194) -> Result<Vec<u8>, Error> {
195 let too_large = || {
196 Error::wire(
197 WireKind::TooLarge,
198 format!(
199 "{} {} response exceeds {limit} bytes",
200 request.method, request.path
201 ),
202 )
203 };
204 if response
205 .content_length()
206 .is_some_and(|length| length > limit as u64)
207 {
208 return Err(too_large());
209 }
210 let mut body = Vec::new();
211 loop {
212 let chunk = tokio::time::timeout(idle_timeout, response.chunk())
213 .await
214 .map_err(|_| Error::wire(WireKind::Transport, "response body went idle"))?
215 .map_err(|err| transport_error(request.method, &request.path, err))?;
216 let Some(chunk) = chunk else { return Ok(body) };
217 if chunk.len() > limit.saturating_sub(body.len()) {
218 return Err(too_large());
219 }
220 body.extend_from_slice(&chunk);
221 }
222}
223
224fn is_failure(status: u16) -> bool {
230 !(200..300).contains(&status)
231}
232
233struct EmptyChunks;
235
236impl ChunkSource for EmptyChunks {
237 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
238 Box::pin(async { Ok(None) })
239 }
240}
241
242struct ReqwestChunks {
243 response: Option<reqwest::Response>,
244 idle_timeout: Duration,
245 method: &'static str,
246 path: String,
247}
248
249impl ChunkSource for ReqwestChunks {
250 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
251 Box::pin(async move {
252 let Some(response) = self.response.as_mut() else {
253 return Ok(None);
254 };
255 match tokio::time::timeout(self.idle_timeout, response.chunk()).await {
256 Ok(Ok(Some(bytes))) => Ok(Some(bytes.to_vec())),
257 Ok(Ok(None)) => {
258 self.response = None;
259 Ok(None)
260 }
261 Ok(Err(err)) => {
262 self.response = None;
263 Err(transport_error(self.method, &self.path, err))
264 }
265 Err(_) => {
266 self.response = None;
267 Err(Error::wire(
270 WireKind::Transport,
271 format!(
272 "{} {} went silent for {}s, longer than the keepalive \
273 interval, so the connection is treated as dead",
274 self.method,
275 self.path,
276 self.idle_timeout.as_secs()
277 ),
278 ))
279 }
280 }
281 })
282 }
283}
284
285impl fmt::Debug for ReqwestBackend {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 f.debug_struct("ReqwestBackend")
288 .field("base_url", &self.base_url)
289 .field("timeout", &self.timeout)
290 .finish()
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
301 fn a_bare_endpoint_host_is_read_as_https() {
302 assert_eq!(
303 normalize_base_url("vm-abc.microvms.aws"),
304 "https://vm-abc.microvms.aws"
305 );
306 assert_eq!(
307 normalize_base_url("http://127.0.0.1:9000"),
308 "http://127.0.0.1:9000",
309 "an explicit scheme is honoured, so a local daemon is still reachable"
310 );
311 assert_eq!(
312 normalize_base_url("https://host/"),
313 "https://host",
314 "a trailing slash would produce a double one on every path"
315 );
316 }
317
318 #[test]
321 fn only_a_success_status_opens_a_stream() {
322 for status in [200, 204, 299] {
323 assert!(!is_failure(status), "{status}");
324 }
325 for status in [101, 199, 300, 404, 500] {
326 assert!(is_failure(status), "{status}");
327 }
328 }
329}