Skip to main content

typesafe_sdk/transport/
hyper.rs

1//! The default transport: one pooled HTTP client per SDK client.
2//!
3//! It is hyper-util's pooled client over hyper-rustls, trusting the operating
4//! system's roots through rustls-platform-verifier, plus any roots the caller
5//! added. The rustls configuration is built here, once per client, because a
6//! verifier with extra roots is something hyper-rustls' own constructors
7//! cannot build.
8//!
9//! Connections are kept for reuse: an idle one is closed after 90 seconds,
10//! and an HTTP/2 connection is kept alive by a PING every 30 seconds, idle or
11//! not, so that a load balancer does not drop it between calls. Nagle's
12//! algorithm is off, because every request and response here is small.
13
14use std::{
15    error::Error as StdError,
16    fmt,
17    future::Future,
18    io,
19    pin::Pin,
20    sync::Arc,
21    task::{Context, Poll},
22    time::Duration,
23};
24
25use ::hyper::body::Incoming;
26use bytes::Bytes;
27use http::{Request, Response};
28use http_body::{Frame, SizeHint};
29use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
30use hyper_util::{
31    client::legacy::{self, connect::HttpConnector},
32    rt::{TokioExecutor, TokioTimer},
33};
34use rustls::{ClientConfig, pki_types::CertificateDer};
35use rustls_platform_verifier::{BuilderVerifierExt as _, Verifier};
36use tower_service::Service;
37
38use super::{Body, BoxError};
39use crate::{error::Error, text};
40
41/// How long an idle pooled connection is kept before it is closed.
42const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
43
44/// How often an HTTP/2 connection is pinged to keep it open.
45const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(30);
46
47/// Which HTTP versions the default transport speaks.
48///
49/// The default is [`Http2Only`](HttpVersion::Http2Only) for an `https` base
50/// URL and [`Auto`](HttpVersion::Auto) for an `http` one.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52#[non_exhaustive]
53pub enum HttpVersion {
54    /// HTTP/2 only: negotiated through TLS ALPN on `https`, and spoken with
55    /// prior knowledge (h2c) on `http`.
56    ///
57    /// Every request of a client shares one multiplexed connection, including
58    /// requests started together on a client that has no connection yet.
59    Http2Only,
60    /// HTTP/2 or HTTP/1.1 as the server chooses through ALPN on `https`, and
61    /// HTTP/1.1 on `http`.
62    ///
63    /// Use it behind a proxy that speaks HTTP/1.1 only. A client that has no
64    /// connection yet may open one per request started at the same time,
65    /// because which version the server speaks is known only once one of them
66    /// is open.
67    Auto,
68}
69
70/// What the default transport is built from.
71pub(crate) struct TransportSettings {
72    pub(crate) version: HttpVersion,
73    /// DER-encoded certificates trusted in addition to the operating system's.
74    pub(crate) extra_roots: Vec<Vec<u8>>,
75    pub(crate) connect_timeout: Option<Duration>,
76}
77
78/// The transport a client uses unless it is given another: a pooled HTTP/1.1
79/// and HTTP/2 client over TLS.
80///
81/// Cloning it shares the connection pool.
82#[derive(Clone)]
83pub struct HyperTransport {
84    client: legacy::Client<HttpsConnector<HttpConnector>, Body>,
85    version: HttpVersion,
86    extra_roots: usize,
87    connect_timeout: Option<Duration>,
88}
89
90impl HyperTransport {
91    /// Builds the transport and its TLS configuration.
92    ///
93    /// Needs no runtime: nothing connects until the first request, which must
94    /// then run on a Tokio runtime with its time driver enabled.
95    ///
96    /// # Errors
97    ///
98    /// Returns an [`ErrorKind::Config`](crate::ErrorKind::Config) error when
99    /// the certificate verifier cannot be built: an added root is not a
100    /// certificate, or the operating system's roots cannot be loaded.
101    pub(crate) fn new(settings: TransportSettings) -> Result<Self, Error> {
102        let TransportSettings { version, extra_roots, connect_timeout } = settings;
103        let root_count = extra_roots.len();
104        let tls = tls_config(extra_roots.into_iter().map(CertificateDer::from).collect())?;
105
106        let mut http = HttpConnector::new();
107        // The TLS layer above decides between `http` and `https`; the TCP
108        // layer has to accept both.
109        http.enforce_http(false);
110        http.set_nodelay(true);
111        http.set_connect_timeout(connect_timeout);
112
113        // hyper-rustls fills ALPN from what is enabled here; offering only
114        // `h2` is what keeps an HTTP/1.1-only server from being accepted
115        // under `Http2Only`.
116        let https = HttpsConnectorBuilder::new().with_tls_config(tls).https_or_http();
117        let connector = match version {
118            HttpVersion::Http2Only => https.enable_http2().wrap_connector(http),
119            HttpVersion::Auto => https.enable_http1().enable_http2().wrap_connector(http),
120        };
121
122        let mut builder = legacy::Client::builder(TokioExecutor::new());
123        // hyper panics on a time-based option that has no timer to run on.
124        builder
125            .timer(TokioTimer::new())
126            .pool_timer(TokioTimer::new())
127            .pool_idle_timeout(POOL_IDLE_TIMEOUT)
128            .http2_keep_alive_interval(KEEP_ALIVE_INTERVAL)
129            .http2_keep_alive_while_idle(true)
130            .http2_only(version == HttpVersion::Http2Only);
131
132        Ok(Self {
133            client: builder.build(connector),
134            version,
135            extra_roots: root_count,
136            connect_timeout,
137        })
138    }
139}
140
141impl fmt::Debug for HyperTransport {
142    /// The settings the transport was built with; the roots as a count.
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        formatter
145            .debug_struct("HyperTransport")
146            .field("http_version", &self.version)
147            .field("extra_roots", &self.extra_roots)
148            .field("connect_timeout", &self.connect_timeout)
149            .finish()
150    }
151}
152
153impl Service<Request<Body>> for HyperTransport {
154    type Response = Response<ResponseBody>;
155    type Error = BoxError;
156    type Future = HyperResponseFuture;
157
158    /// Always ready: the pool takes any number of requests.
159    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
160        Poll::Ready(Ok(()))
161    }
162
163    fn call(&mut self, request: Request<Body>) -> HyperResponseFuture {
164        HyperResponseFuture {
165            inner: self.client.request(request),
166            connect_timeout: self.connect_timeout,
167        }
168    }
169}
170
171/// The response of one request sent by [`HyperTransport`].
172#[must_use = "futures do nothing unless polled"]
173pub struct HyperResponseFuture {
174    inner: legacy::ResponseFuture,
175    connect_timeout: Option<Duration>,
176}
177
178impl fmt::Debug for HyperResponseFuture {
179    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180        formatter.debug_struct("HyperResponseFuture").finish_non_exhaustive()
181    }
182}
183
184impl Future for HyperResponseFuture {
185    type Output = Result<Response<ResponseBody>, BoxError>;
186
187    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
188        // Both fields are `Unpin`, so the pinned reference can be turned back
189        // into a plain one and the inner future pinned in place again.
190        let this = self.get_mut();
191        match Pin::new(&mut this.inner).poll(cx) {
192            Poll::Pending => Poll::Pending,
193            Poll::Ready(Ok(response)) => Poll::Ready(Ok(response.map(ResponseBody))),
194            Poll::Ready(Err(error)) => Poll::Ready(Err(failure(error, this.connect_timeout))),
195        }
196    }
197}
198
199/// The body of a response [`HyperTransport`] received, read frame by frame as
200/// it arrives.
201///
202/// It is hyper's own body under a name of this crate's, so that a new major
203/// version of hyper is not a breaking change here. Every call is forwarded as
204/// it is, and nothing is boxed or copied: the length the server declared is
205/// still what [`size_hint`](http_body::Body::size_hint) reports, which is what
206/// lets a response over the limit be refused before a byte of it is read.
207///
208/// `Debug` prints no part of the body.
209pub struct ResponseBody(Incoming);
210
211impl fmt::Debug for ResponseBody {
212    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213        formatter.debug_struct("ResponseBody").finish_non_exhaustive()
214    }
215}
216
217impl http_body::Body for ResponseBody {
218    type Data = Bytes;
219    type Error = BoxError;
220
221    fn poll_frame(
222        self: Pin<&mut Self>,
223        cx: &mut Context<'_>,
224    ) -> Poll<Option<Result<Frame<Bytes>, BoxError>>> {
225        // hyper's body is `Unpin`, so the pinned reference can be turned back
226        // into a plain one and the body pinned in place again, with no
227        // `unsafe` projection. Its error is boxed only when one occurs.
228        Pin::new(&mut self.get_mut().0).poll_frame(cx).map_err(Into::into)
229    }
230
231    fn is_end_stream(&self) -> bool {
232        self.0.is_end_stream()
233    }
234
235    fn size_hint(&self) -> SizeHint {
236        self.0.size_hint()
237    }
238}
239
240/// What a failed request becomes: a timeout when it was the connect timeout
241/// that ran out, the client's own error otherwise.
242fn failure(error: legacy::Error, connect_timeout: Option<Duration>) -> BoxError {
243    match connect_timeout {
244        Some(timeout) if error.is_connect() && timed_out(&error) => {
245            Box::new(Error::timeout(timeout))
246        }
247        _ => Box::new(error),
248    }
249}
250
251/// Whether anything in the chain of `error` is an I/O error that timed out.
252fn timed_out(error: &(dyn StdError + 'static)) -> bool {
253    let mut link = Some(error);
254    while let Some(current) = link {
255        if current
256            .downcast_ref::<io::Error>()
257            .is_some_and(|io| io.kind() == io::ErrorKind::TimedOut)
258        {
259            return true;
260        }
261        link = current.source();
262    }
263    false
264}
265
266/// The rustls configuration: TLS 1.2 and 1.3 with aws-lc-rs, the operating
267/// system's roots, and `extra_roots` on top of them.
268///
269/// The crypto provider is named rather than taken from the process default,
270/// so a second provider elsewhere in the program cannot change it. ALPN is
271/// left empty: hyper-rustls sets it from the versions the connector enables,
272/// and refuses a configuration that already has it.
273fn tls_config(extra_roots: Vec<CertificateDer<'static>>) -> Result<ClientConfig, Error> {
274    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
275    let builder = ClientConfig::builder_with_provider(Arc::clone(&provider))
276        .with_safe_default_protocol_versions()
277        .map_err(verifier_error)?;
278    let config = if extra_roots.is_empty() {
279        builder.with_platform_verifier().map_err(verifier_error)?.with_no_client_auth()
280    } else {
281        let verifier =
282            Verifier::new_with_extra_roots(extra_roots, provider).map_err(verifier_error)?;
283        builder
284            .dangerous()
285            .with_custom_certificate_verifier(Arc::new(verifier))
286            .with_no_client_auth()
287    };
288    Ok(config)
289}
290
291/// The error for a TLS configuration that cannot be built.
292///
293/// The verifier's text can quote a certificate the caller added or the
294/// platform's own diagnostics, so it is escaped and bounded like any text
295/// this SDK did not write.
296fn verifier_error(error: rustls::Error) -> Error {
297    Error::config(format!(
298        "The TLS certificate verifier could not be built: {}.",
299        text::bounded(&error, text::MAX_MESSAGE_CHARS)
300    ))
301}
302
303#[cfg(test)]
304#[path = "hyper_tests.rs"]
305mod tests;