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};
14use microvms_app::session::http::{
15    ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream,
16};
17
18/// The production backend: one pooled reqwest client.
19///
20/// Pooled rather than a client per request, because the daemon drains a bounded prefix
21/// of a rejected body specifically so pooled connections keep working, and throwing the
22/// pool away per request discards that.
23pub struct ReqwestBackend {
24    client: reqwest::Client,
25    base_url: String,
26    timeout: Duration,
27}
28
29impl ReqwestBackend {
30    /// A backend rooted at `base_url`.
31    ///
32    /// A bare host is read as `https`. The endpoint the platform hands back is a
33    /// hostname, and defaulting to plain HTTP there would send a bearer token in
34    /// clear text on the strength of a missing prefix.
35    pub fn new(base_url: &str, timeout: Duration) -> Result<Self, Error> {
36        let client = reqwest::Client::builder()
37            .timeout(timeout)
38            .build()
39            .map_err(|err| {
40                Error::new(
41                    microvms_app::error::ErrorKind::Unexpected,
42                    format!("could not build an HTTP client: {err}"),
43                )
44                .with_source(err)
45            })?;
46        Ok(Self {
47            client,
48            base_url: normalize_base_url(base_url),
49            timeout,
50        })
51    }
52
53    fn url(&self, path: &str) -> String {
54        format!("{}{path}", self.base_url)
55    }
56
57    fn build(&self, request: &HttpRequest) -> reqwest::RequestBuilder {
58        let mut builder = self
59            .client
60            .request(
61                reqwest::Method::from_bytes(request.method.as_bytes())
62                    .unwrap_or(reqwest::Method::GET),
63                self.url(&request.path),
64            )
65            .timeout(request.timeout.unwrap_or(self.timeout));
66        for (name, value) in &request.headers {
67            builder = builder.header(name, value);
68        }
69        if !request.body.is_empty() {
70            builder = builder.body(request.body.clone());
71        }
72        builder
73    }
74}
75
76/// A bare host becomes `https://`, and a trailing slash is dropped so a path can be
77/// concatenated without producing a double one.
78fn normalize_base_url(base_url: &str) -> String {
79    let with_scheme = if base_url.starts_with("http://") || base_url.starts_with("https://") {
80        base_url.to_string()
81    } else {
82        format!("https://{base_url}")
83    };
84    with_scheme.trim_end_matches('/').to_string()
85}
86
87/// A transport failure. Retryable, because it says nothing about the daemon's state.
88fn transport_error(method: &str, path: &str, err: reqwest::Error) -> Error {
89    Error::wire(
90        WireKind::Transport,
91        format!("{method} {path} failed on the wire: {err}"),
92    )
93    .with_source(err)
94}
95
96fn collect_headers(response: &reqwest::Response) -> HashMap<String, String> {
97    response
98        .headers()
99        .iter()
100        .map(|(name, value)| {
101            (
102                name.as_str().to_string(),
103                String::from_utf8_lossy(value.as_bytes()).into_owned(),
104            )
105        })
106        .collect()
107}
108
109impl HttpBackend for ReqwestBackend {
110    fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
111        Box::pin(async move {
112            let response = self
113                .build(&request)
114                .send()
115                .await
116                .map_err(|err| transport_error(request.method, &request.path, err))?;
117            let status = response.status().as_u16();
118            let headers = collect_headers(&response);
119            let body = response
120                .bytes()
121                .await
122                .map_err(|err| transport_error(request.method, &request.path, err))?
123                .to_vec();
124            Ok(HttpResponse {
125                status,
126                headers,
127                body,
128            })
129        })
130    }
131
132    fn open_stream(
133        &self,
134        request: HttpRequest,
135        idle_timeout: Duration,
136    ) -> BoxFuture<'_, Result<OpenStream, Error>> {
137        Box::pin(async move {
138            let response = self
139                .build(&request)
140                // No overall timeout on a stream: the bound is `idle_timeout`, applied
141                // per chunk below. An overall one would cut a healthy long-running
142                // command off mid-output.
143                .timeout(Duration::MAX)
144                .send()
145                .await
146                .map_err(|err| transport_error(request.method, &request.path, err))?;
147            let status = response.status().as_u16();
148            let headers = collect_headers(&response);
149
150            // The status is read before any body byte. On a failure the body is
151            // collected so the typed error carries the daemon's detail string.
152            if is_failure(status) {
153                let body = response
154                    .bytes()
155                    .await
156                    .map_err(|err| transport_error(request.method, &request.path, err))?
157                    .to_vec();
158                return Ok((
159                    HttpResponse {
160                        status,
161                        headers,
162                        body,
163                    },
164                    Box::new(EmptyChunks) as Box<dyn ChunkSource>,
165                ));
166            }
167
168            let head = HttpResponse {
169                status,
170                headers,
171                body: Vec::new(),
172            };
173            let chunks = ReqwestChunks {
174                response: Some(response),
175                idle_timeout,
176                method: request.method,
177                path: request.path,
178            };
179            Ok((head, Box::new(chunks) as Box<dyn ChunkSource>))
180        })
181    }
182}
183
184/// A status `open_stream` doesn't stream: anything but a 2xx.
185///
186/// Apart from `open_stream` so it has an offline test. Only a daemon on the other end reaches
187/// `open_stream`, so `.cargo/mutants.toml` excludes its return value, and a check left inline
188/// there would never be mutated.
189fn is_failure(status: u16) -> bool {
190    !(200..300).contains(&status)
191}
192
193/// No body at all, for the failure path where the head already carries everything.
194struct EmptyChunks;
195
196impl ChunkSource for EmptyChunks {
197    fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
198        Box::pin(async { Ok(None) })
199    }
200}
201
202struct ReqwestChunks {
203    response: Option<reqwest::Response>,
204    idle_timeout: Duration,
205    method: &'static str,
206    path: String,
207}
208
209impl ChunkSource for ReqwestChunks {
210    fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
211        Box::pin(async move {
212            let Some(response) = self.response.as_mut() else {
213                return Ok(None);
214            };
215            match tokio::time::timeout(self.idle_timeout, response.chunk()).await {
216                Ok(Ok(Some(bytes))) => Ok(Some(bytes.to_vec())),
217                Ok(Ok(None)) => {
218                    self.response = None;
219                    Ok(None)
220                }
221                Ok(Err(err)) => {
222                    self.response = None;
223                    Err(transport_error(self.method, &self.path, err))
224                }
225                Err(_) => {
226                    self.response = None;
227                    // Retryable by construction: silence past the keepalive interval
228                    // means the connection is dead, and the exec is untouched.
229                    Err(Error::wire(
230                        WireKind::Transport,
231                        format!(
232                            "{} {} went silent for {}s, longer than the keepalive \
233                             interval, so the connection is treated as dead",
234                            self.method,
235                            self.path,
236                            self.idle_timeout.as_secs()
237                        ),
238                    ))
239                }
240            }
241        })
242    }
243}
244
245impl fmt::Debug for ReqwestBackend {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.debug_struct("ReqwestBackend")
248            .field("base_url", &self.base_url)
249            .field("timeout", &self.timeout)
250            .finish()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    /// A bare host is `https`, because the platform hands back a hostname and plain
259    /// HTTP there would put a bearer token on the wire in clear text.
260    #[test]
261    fn a_bare_endpoint_host_is_read_as_https() {
262        assert_eq!(
263            normalize_base_url("vm-abc.microvms.aws"),
264            "https://vm-abc.microvms.aws"
265        );
266        assert_eq!(
267            normalize_base_url("http://127.0.0.1:9000"),
268            "http://127.0.0.1:9000",
269            "an explicit scheme is honoured, so a local daemon is still reachable"
270        );
271        assert_eq!(
272            normalize_base_url("https://host/"),
273            "https://host",
274            "a trailing slash would produce a double one on every path"
275        );
276    }
277
278    /// A stream opens on a 2xx only. Anything else collects its body, so the typed error
279    /// carries the daemon's detail string.
280    #[test]
281    fn only_a_success_status_opens_a_stream() {
282        for status in [200, 204, 299] {
283            assert!(!is_failure(status), "{status}");
284        }
285        for status in [101, 199, 300, 404, 500] {
286            assert!(is_failure(status), "{status}");
287        }
288    }
289}