Skip to main content

microvms_edges/session/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The production HTTP backend: one pooled reqwest client.
3//!
4//! [`HttpBackend`] and its request and response shapes are `microvms_app::session::http`, and
5//! why a session talks through a trait rather than reqwest directly is written there.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::time::Duration;
10
11use futures_util::future::BoxFuture;
12
13use microvms_app::error::{Error, WireKind};
14
15// File downloads share this collector; keep their ceiling aligned with the daemon's
16// default upload budget. Error replies only need room for structured diagnostics.
17const RESPONSE_LIMIT: usize = 512 << 20;
18const ERROR_LIMIT: usize = 64 << 10;
19use microvms_app::session::http::{
20    ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream,
21};
22
23/// The production backend: one pooled reqwest client.
24///
25/// Pooled rather than a client per request, because the daemon drains a bounded prefix
26/// of a rejected body specifically so pooled connections keep working, and throwing the
27/// pool away per request discards that.
28pub struct ReqwestBackend {
29    client: reqwest::Client,
30    base_url: String,
31    timeout: Duration,
32}
33
34impl ReqwestBackend {
35    /// A backend rooted at `base_url`.
36    ///
37    /// A bare host is read as `https`. The endpoint the platform hands back is a
38    /// hostname, and defaulting to plain HTTP there would send a bearer token in
39    /// clear text on the strength of a missing prefix.
40    pub fn new(base_url: &str, timeout: Duration) -> Result<Self, Error> {
41        let client = reqwest::Client::builder()
42            .timeout(timeout)
43            // Every daemon request stays on the endpoint it authenticated.
44            .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
83/// A bare host becomes `https://`, and a trailing slash is dropped so a path can be
84/// concatenated without producing a double one.
85fn 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
94/// A transport failure. Retryable, because it says nothing about the daemon's state.
95fn 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                // No overall timeout on a stream: the bound is `idle_timeout`, applied
149                // per chunk below. An overall one would cut a healthy long-running
150                // command off mid-output.
151                .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            // The status is read before any body byte. On a failure the body is
159            // collected so the typed error carries the daemon's detail string.
160            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
188/// Collect only a bounded response, including when it omits Content-Length.
189async 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
224/// A status `open_stream` doesn't stream: anything but a 2xx.
225///
226/// Apart from `open_stream` so it has an offline test. Only a daemon on the other end reaches
227/// `open_stream`, so `.cargo/mutants.toml` excludes its return value, and a check left inline
228/// there would never be mutated.
229fn is_failure(status: u16) -> bool {
230    !(200..300).contains(&status)
231}
232
233/// No body at all, for the failure path where the head already carries everything.
234struct 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                    // Retryable by construction: silence past the keepalive interval
268                    // means the connection is dead, and the exec is untouched.
269                    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    /// A bare host is `https`, because the platform hands back a hostname and plain
299    /// HTTP there would put a bearer token on the wire in clear text.
300    #[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    /// A stream opens on a 2xx only. Anything else collects its body, so the typed error
319    /// carries the daemon's detail string.
320    #[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}