Skip to main content

ullun/
client.rs

1//! Outbound HTTP client
2//!
3//! Services call other services. [`HttpClient`] is a small client for that with
4//! the settings production calls need and ad-hoc code forgets:
5//!
6//! * timeouts for connecting and for the whole attempt, and a cap on the
7//!   response size
8//! * connection pooling (keep-alive) per host
9//! * retries with exponential backoff and jitter for connection failures and
10//!   `502/503/504/429` (honouring `Retry-After`), only for requests that are safe
11//!   to repeat: idempotent methods or requests carrying an `Idempotency-Key`
12//! * redirects (followed, up to a limit; credentials are dropped when the
13//!   target is another origin)
14//! * an SSRF guard ([`HttpClient::public_only`]): every connection, including
15//!   those after a redirect, goes to a vetted public address
16//! * HTTPS with the Mozilla root certificates (feature `client-tls`), over
17//!   HTTP/2 when the server offers it (one multiplexed connection per host,
18//!   see [`HttpClient::http1_only`]); h2c with [`HttpClient::http2_prior_knowledge`]
19//!
20//! ```no_run
21//! use std::time::Duration;
22//! use ullun::client::HttpClient;
23//!
24//! # async fn run() -> Result<(), ullun::client::ClientError> {
25//! let client = HttpClient::new()
26//!     .timeout(Duration::from_secs(5))
27//!     .retries(2)
28//!     .public_only();
29//! let res = client.get("http://example.com/api/items?limit=10").send().await?;
30//! if res.status() == 200 {
31//!     let items: serde_json::Value = res.json()?;
32//!     println!("{items}");
33//! }
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! [`ClientError`] converts into [`Error`](crate::error::Error) (`504` for
39//! timeouts, `502` otherwise), so handlers can use `?` on client calls.
40
41use bytes::Bytes;
42use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
43use http_body_util::{BodyExt, Full};
44use hyper::body::Incoming;
45use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
46use serde::{de::DeserializeOwned, Serialize};
47use std::collections::HashMap;
48use std::net::{IpAddr, SocketAddr};
49use std::sync::{Arc, Mutex};
50use std::time::{Duration, Instant};
51use tokio::io::{AsyncRead, AsyncWrite};
52use tokio::net::TcpStream;
53
54/// Why a client call failed
55#[derive(Debug)]
56#[non_exhaustive]
57pub enum ClientError {
58    /// The URL is not a usable `http://` or `https://` URL
59    InvalidUrl(String),
60    /// Name resolution or connecting failed
61    Connect(String),
62    /// The TLS handshake failed or `https` is not available
63    Tls(String),
64    /// The target resolved to a non-public address (with [`HttpClient::public_only`])
65    Blocked(IpAddr),
66    /// The attempt took longer than the timeout
67    Timeout,
68    /// The peer did not speak valid HTTP or closed the connection early
69    Protocol(String),
70    /// The response body exceeds [`HttpClient::max_response_bytes`]
71    TooLarge(usize),
72    /// More redirects than [`HttpClient::max_redirects`]
73    TooManyRedirects,
74    /// The response body is not the expected JSON / UTF-8
75    Decode(String),
76}
77
78impl std::fmt::Display for ClientError {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            ClientError::InvalidUrl(u) => write!(f, "invalid URL: {u}"),
82            ClientError::Connect(e) => write!(f, "connection failed: {e}"),
83            ClientError::Tls(e) => write!(f, "TLS error: {e}"),
84            ClientError::Blocked(ip) => write!(f, "target address {ip} is not public"),
85            ClientError::Timeout => f.write_str("request timed out"),
86            ClientError::Protocol(e) => write!(f, "invalid response: {e}"),
87            ClientError::TooLarge(max) => write!(f, "response larger than {max} bytes"),
88            ClientError::TooManyRedirects => f.write_str("too many redirects"),
89            ClientError::Decode(e) => write!(f, "cannot decode response: {e}"),
90        }
91    }
92}
93
94impl std::error::Error for ClientError {}
95
96impl From<ClientError> for crate::error::Error {
97    fn from(e: ClientError) -> Self {
98        let status = if matches!(e, ClientError::Timeout) {
99            504
100        } else {
101            502
102        };
103        crate::error::Error::custom(status, format!("Upstream request failed: {e}"))
104    }
105}
106
107trait Io: AsyncRead + AsyncWrite + Unpin + Send {}
108impl<T: AsyncRead + AsyncWrite + Unpin + Send> Io for T {}
109
110/// One connection, speaking HTTP/1.1 (used by one request at a time) or
111/// HTTP/2 (cloned for every request, multiplexed)
112enum Sender {
113    H1(hyper::client::conn::http1::SendRequest<Full<Bytes>>),
114    H2(hyper::client::conn::http2::SendRequest<Full<Bytes>>),
115}
116
117impl Sender {
118    fn is_h2(&self) -> bool {
119        matches!(self, Sender::H2(_))
120    }
121
122    fn is_closed(&self) -> bool {
123        match self {
124            Sender::H1(s) => s.is_closed(),
125            Sender::H2(s) => s.is_closed(),
126        }
127    }
128
129    fn duplicate(&self) -> Option<Sender> {
130        match self {
131            Sender::H1(_) => None,
132            Sender::H2(s) => Some(Sender::H2(s.clone())),
133        }
134    }
135
136    async fn send(
137        &mut self,
138        req: http::Request<Full<Bytes>>,
139    ) -> Result<http::Response<Incoming>, hyper::Error> {
140        match self {
141            Sender::H1(s) => s.send_request(req).await,
142            Sender::H2(s) => s.send_request(req).await,
143        }
144    }
145}
146
147#[derive(Hash, PartialEq, Eq, Clone)]
148struct PoolKey {
149    https: bool,
150    host: String,
151    port: u16,
152}
153
154struct Idle {
155    sender: Sender,
156    since: Instant,
157}
158
159struct Shared {
160    pool: Mutex<HashMap<PoolKey, Vec<Idle>>>,
161    /// Offers `h2` and `http/1.1` through ALPN
162    #[cfg(feature = "client-tls")]
163    tls: tokio_rustls::TlsConnector,
164    /// Offers `http/1.1` only
165    #[cfg(feature = "client-tls")]
166    tls_h1: tokio_rustls::TlsConnector,
167}
168
169/// HTTP client, see the [module documentation](self). Cloning shares the connection pool.
170#[derive(Clone)]
171pub struct HttpClient {
172    shared: Arc<Shared>,
173    timeout: Duration,
174    connect_timeout: Duration,
175    max_response: usize,
176    max_redirects: usize,
177    retries: u32,
178    backoff: Duration,
179    public_only: bool,
180    user_agent: String,
181    headers: HeaderMap,
182    idle_timeout: Duration,
183    max_idle_per_host: usize,
184    http2: bool,
185    h2_prior_knowledge: bool,
186}
187
188impl Default for HttpClient {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194impl HttpClient {
195    pub fn new() -> Self {
196        Self {
197            shared: Arc::new(Shared {
198                pool: Mutex::new(HashMap::new()),
199                #[cfg(feature = "client-tls")]
200                tls: tls_connectors(&[]).expect("default TLS configuration").0,
201                #[cfg(feature = "client-tls")]
202                tls_h1: tls_connectors(&[]).expect("default TLS configuration").1,
203            }),
204            timeout: Duration::from_secs(30),
205            connect_timeout: Duration::from_secs(10),
206            max_response: 10 * 1024 * 1024,
207            max_redirects: 5,
208            retries: 0,
209            backoff: Duration::from_millis(200),
210            public_only: false,
211            user_agent: concat!("ullun-api/", env!("CARGO_PKG_VERSION")).to_string(),
212            headers: HeaderMap::new(),
213            idle_timeout: Duration::from_secs(30),
214            max_idle_per_host: 8,
215            http2: true,
216            h2_prior_knowledge: false,
217        }
218    }
219
220    /// Longest a single attempt (connect, send, read the whole response) may take (default 30 s)
221    pub fn timeout(mut self, timeout: Duration) -> Self {
222        self.timeout = timeout;
223        self
224    }
225
226    /// Longest name resolution + TCP/TLS connect may take (default 10 s)
227    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
228        self.connect_timeout = timeout;
229        self
230    }
231
232    /// Largest response body accepted (default 10 MiB)
233    pub fn max_response_bytes(mut self, max: usize) -> Self {
234        self.max_response = max;
235        self
236    }
237
238    /// Redirects followed per call (default 5, 0 returns the 3xx response itself)
239    pub fn max_redirects(mut self, max: usize) -> Self {
240        self.max_redirects = max;
241        self
242    }
243
244    /// Extra attempts after a failure that is safe to repeat (default 0)
245    pub fn retries(mut self, retries: u32) -> Self {
246        self.retries = retries;
247        self
248    }
249
250    /// First retry delay, doubled each time with jitter (default 200 ms)
251    pub fn retry_backoff(mut self, base: Duration) -> Self {
252        self.backoff = base;
253        self
254    }
255
256    /// Only connect to public addresses: loopback, private, link-local and
257    /// cloud-metadata targets are refused with [`ClientError::Blocked`]. Use it
258    /// whenever the URL comes from a user (webhooks, link previews, imports).
259    pub fn public_only(mut self) -> Self {
260        self.public_only = true;
261        self
262    }
263
264    pub fn user_agent(mut self, agent: &str) -> Self {
265        self.user_agent = agent.to_string();
266        self
267    }
268
269    /// Header sent with every request (unless the request sets it itself)
270    pub fn default_header(mut self, name: &str, value: &str) -> Self {
271        if let (Ok(n), Ok(v)) = (
272            HeaderName::from_bytes(name.as_bytes()),
273            HeaderValue::from_str(value),
274        ) {
275            self.headers.insert(n, v);
276        }
277        self
278    }
279
280    /// Keep idle connections this long for reuse (default 30 s, zero disables pooling)
281    pub fn pool_idle_timeout(mut self, timeout: Duration) -> Self {
282        self.idle_timeout = timeout;
283        self
284    }
285
286    /// Speak HTTP/1.1 only, even to servers that offer HTTP/2. By default `https`
287    /// connections negotiate HTTP/2 through ALPN (feature `client-tls`) and use
288    /// one multiplexed connection per host.
289    pub fn http1_only(mut self) -> Self {
290        self.http2 = false;
291        self
292    }
293
294    /// Speak HTTP/2 over plain `http://` connections without negotiation
295    /// ("prior knowledge", h2c), for services known to accept it. A server that
296    /// only knows HTTP/1.1 will fail the connection.
297    pub fn http2_prior_knowledge(mut self) -> Self {
298        self.h2_prior_knowledge = true;
299        self
300    }
301
302    /// Trust these PEM root certificates in addition to the built-in ones, e.g.
303    /// a private CA (feature `client-tls`)
304    #[cfg(feature = "client-tls")]
305    pub fn add_root_certificates_pem(mut self, pem: &[u8]) -> Result<Self, ClientError> {
306        let (tls, tls_h1) = tls_connectors(&[pem])?;
307        let shared = Shared {
308            pool: Mutex::new(HashMap::new()),
309            tls,
310            tls_h1,
311        };
312        self.shared = Arc::new(shared);
313        Ok(self)
314    }
315
316    pub fn request(&self, method: Method, url: &str) -> RequestBuilder {
317        RequestBuilder {
318            client: self.clone(),
319            method,
320            url: url.to_string(),
321            headers: HeaderMap::new(),
322            body: Bytes::new(),
323            timeout: None,
324            error: None,
325        }
326    }
327
328    pub fn get(&self, url: &str) -> RequestBuilder {
329        self.request(Method::GET, url)
330    }
331    pub fn post(&self, url: &str) -> RequestBuilder {
332        self.request(Method::POST, url)
333    }
334    pub fn put(&self, url: &str) -> RequestBuilder {
335        self.request(Method::PUT, url)
336    }
337    pub fn patch(&self, url: &str) -> RequestBuilder {
338        self.request(Method::PATCH, url)
339    }
340    pub fn delete(&self, url: &str) -> RequestBuilder {
341        self.request(Method::DELETE, url)
342    }
343    pub fn head(&self, url: &str) -> RequestBuilder {
344        self.request(Method::HEAD, url)
345    }
346}
347
348/// A request under construction, created by the verbs of [`HttpClient`]
349pub struct RequestBuilder {
350    client: HttpClient,
351    method: Method,
352    url: String,
353    headers: HeaderMap,
354    body: Bytes,
355    timeout: Option<Duration>,
356    error: Option<ClientError>,
357}
358
359impl RequestBuilder {
360    pub fn header(mut self, name: &str, value: &str) -> Self {
361        match (
362            HeaderName::from_bytes(name.as_bytes()),
363            HeaderValue::from_str(value),
364        ) {
365            (Ok(n), Ok(v)) => {
366                self.headers.insert(n, v);
367            }
368            _ => self.error = Some(ClientError::InvalidUrl(format!("invalid header {name}"))),
369        }
370        self
371    }
372
373    pub fn bearer(self, token: &str) -> Self {
374        self.header("authorization", &format!("Bearer {token}"))
375    }
376
377    pub fn json<T: Serialize>(mut self, value: &T) -> Self {
378        match serde_json::to_vec(value) {
379            Ok(body) => {
380                self.body = Bytes::from(body);
381                self.header("content-type", "application/json")
382            }
383            Err(e) => {
384                self.error = Some(ClientError::Decode(e.to_string()));
385                self
386            }
387        }
388    }
389
390    pub fn form<T: Serialize>(mut self, value: &T) -> Self {
391        match serde_urlencoded::to_string(value) {
392            Ok(body) => {
393                self.body = Bytes::from(body);
394                self.header("content-type", "application/x-www-form-urlencoded")
395            }
396            Err(e) => {
397                self.error = Some(ClientError::Decode(e.to_string()));
398                self
399            }
400        }
401    }
402
403    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
404        self.body = body.into();
405        self
406    }
407
408    /// Append query parameters (percent-encoded)
409    pub fn query<T: Serialize>(mut self, params: &T) -> Self {
410        match serde_urlencoded::to_string(params) {
411            Ok(q) if !q.is_empty() => {
412                self.url
413                    .push(if self.url.contains('?') { '&' } else { '?' });
414                self.url.push_str(&q);
415            }
416            Ok(_) => {}
417            Err(e) => self.error = Some(ClientError::Decode(e.to_string())),
418        }
419        self
420    }
421
422    /// Continue the trace of the request being handled: sends `traceparent`
423    /// with the same trace id and this service's span as the callee's parent,
424    /// and passes `tracestate` on (needs [`TraceLayer`](crate::trace::TraceLayer);
425    /// does nothing without it)
426    pub fn trace(self, incoming: &crate::request::Request) -> Self {
427        let Some(ctx) = incoming.trace() else {
428            return self;
429        };
430        let this = self.header("traceparent", &ctx.traceparent());
431        match &ctx.tracestate {
432            Some(state) => this.header("tracestate", state),
433            None => this,
434        }
435    }
436
437    /// Per-attempt timeout for this request only
438    pub fn timeout(mut self, timeout: Duration) -> Self {
439        self.timeout = Some(timeout);
440        self
441    }
442
443    pub async fn send(self) -> Result<ClientResponse, ClientError> {
444        if let Some(e) = self.error {
445            return Err(e);
446        }
447        let c = &self.client;
448        let timeout = self.timeout.unwrap_or(c.timeout);
449        let repeatable = is_repeatable(&self.method, &self.headers);
450        let mut attempt = 0;
451        loop {
452            let outcome = match tokio::time::timeout(timeout, c.run(&self)).await {
453                Ok(r) => r,
454                Err(_) => Err(ClientError::Timeout),
455            };
456            let retry_after = match &outcome {
457                Ok(res) if retryable_status(res.status) => Some(res.retry_after()),
458                Ok(_) => None,
459                Err(e) if retryable_error(e) => Some(None),
460                Err(_) => None,
461            };
462            match retry_after {
463                Some(hint) if repeatable && attempt < c.retries => {
464                    let wait = hint.unwrap_or_else(|| c.delay(attempt));
465                    // do not sleep past a sensible bound for a server's Retry-After
466                    tokio::time::sleep(wait.min(Duration::from_secs(10))).await;
467                    attempt += 1;
468                }
469                _ => return outcome,
470            }
471        }
472    }
473}
474
475fn is_repeatable(method: &Method, headers: &HeaderMap) -> bool {
476    matches!(
477        *method,
478        Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
479    ) || headers.contains_key("idempotency-key")
480}
481
482fn retryable_status(status: StatusCode) -> bool {
483    matches!(status.as_u16(), 429 | 502 | 503 | 504)
484}
485
486fn retryable_error(e: &ClientError) -> bool {
487    matches!(
488        e,
489        ClientError::Connect(_) | ClientError::Timeout | ClientError::Protocol(_)
490    )
491}
492
493impl HttpClient {
494    /// Exponential backoff with up to 50 % jitter
495    fn delay(&self, attempt: u32) -> Duration {
496        let base = self.backoff.saturating_mul(1 << attempt.min(8));
497        let jitter = crate::security::random_bytes(1)
498            .ok()
499            .and_then(|b| b.first().copied())
500            .unwrap_or(0) as f64
501            / 255.0
502            * 0.5;
503        base.mul_f64(1.0 + jitter)
504    }
505
506    /// One attempt, following redirects
507    async fn run(&self, req: &RequestBuilder) -> Result<ClientResponse, ClientError> {
508        let mut url = req.url.clone();
509        let mut method = req.method.clone();
510        let mut body = req.body.clone();
511        let mut headers = req.headers.clone();
512        let origin = origin_of(&req.url)?;
513        for _ in 0..=self.max_redirects {
514            let uri: Uri = url
515                .parse()
516                .map_err(|_| ClientError::InvalidUrl(url.clone()))?;
517            let res = self.exchange(&uri, &method, &headers, &body).await?;
518            let status = res.status;
519            let location = res
520                .headers
521                .get(http::header::LOCATION)
522                .and_then(|v| v.to_str().ok());
523            if self.max_redirects == 0 || !status.is_redirection() || location.is_none() {
524                return Ok(res);
525            }
526            let next = resolve_location(&uri, location.unwrap_or_default())
527                .ok_or_else(|| ClientError::Protocol("bad redirect location".into()))?;
528            // 301/302/303 turn into GET without a body; 307/308 repeat the request
529            if matches!(status.as_u16(), 301..=303) && method != Method::HEAD {
530                method = Method::GET;
531                body = Bytes::new();
532                headers.remove(http::header::CONTENT_TYPE);
533                headers.remove(http::header::CONTENT_LENGTH);
534            }
535            if origin_of(&next)? != origin {
536                // never hand credentials to another origin
537                headers.remove(http::header::AUTHORIZATION);
538                headers.remove(http::header::COOKIE);
539            }
540            url = next;
541        }
542        Err(ClientError::TooManyRedirects)
543    }
544
545    async fn exchange(
546        &self,
547        uri: &Uri,
548        method: &Method,
549        extra: &HeaderMap,
550        body: &Bytes,
551    ) -> Result<ClientResponse, ClientError> {
552        let (https, host, port) = target(uri)?;
553        let key = PoolKey {
554            https,
555            host: host.clone(),
556            port,
557        };
558        let path = uri
559            .path_and_query()
560            .map(|p| p.as_str())
561            .unwrap_or("/")
562            .to_string();
563
564        let build = |h2: bool| -> Result<http::Request<Full<Bytes>>, ClientError> {
565            let host_value = if (https && port == 443) || (!https && port == 80) {
566                host.clone()
567            } else {
568                format!("{host}:{port}")
569            };
570            // HTTP/2 carries the authority in the request URI, not in a Host header
571            let target = if h2 {
572                format!(
573                    "{}://{host_value}{path}",
574                    if https { "https" } else { "http" }
575                )
576            } else {
577                path.clone()
578            };
579            let mut b = http::Request::builder().method(method.clone()).uri(target);
580            let h = b.headers_mut().expect("builder has headers");
581            if !h2 {
582                h.insert(
583                    http::header::HOST,
584                    HeaderValue::from_str(&host_value)
585                        .map_err(|_| ClientError::InvalidUrl(uri.to_string()))?,
586                );
587            }
588            if let Ok(agent) = HeaderValue::from_str(&self.user_agent) {
589                h.insert(http::header::USER_AGENT, agent);
590            }
591            for (n, v) in self.headers.iter().chain(extra.iter()) {
592                // connection-specific headers are not allowed in HTTP/2 (RFC 9113, 8.2.2)
593                if h2 && is_connection_specific(n) {
594                    continue;
595                }
596                h.insert(n.clone(), v.clone());
597            }
598            if !body.is_empty() || matches!(*method, Method::POST | Method::PUT | Method::PATCH) {
599                h.insert(http::header::CONTENT_LENGTH, body.len().into());
600            }
601            b.body(Full::new(body.clone()))
602                .map_err(|e| ClientError::Protocol(e.to_string()))
603        };
604
605        // a pooled connection may have been closed by the peer meanwhile: one
606        // fresh attempt is then always safe if nothing was received
607        let mut sender = self.checkout(&key);
608        let mut from_pool = sender.is_some();
609        let mut response = None;
610        if let Some(s) = sender.as_mut() {
611            match s.send(build(s.is_h2())?).await {
612                Ok(r) => response = Some(r),
613                Err(_) => {
614                    sender = None;
615                    from_pool = false;
616                }
617            }
618        }
619        let response = match response {
620            Some(r) => r,
621            None => {
622                let mut fresh = self.connect(&key).await?;
623                let r = fresh
624                    .send(build(fresh.is_h2())?)
625                    .await
626                    .map_err(|e| ClientError::Protocol(e.to_string()))?;
627                sender = Some(fresh);
628                r
629            }
630        };
631
632        let (parts, incoming) = response.into_parts();
633        let bytes = read_limited(incoming, self.max_response).await?;
634        if let Some(s) = sender {
635            // an HTTP/2 connection stays in the pool (shared) once it is there
636            let keep = s.is_h2() && !from_pool
637                || (!s.is_h2()
638                    && parts.version == http::Version::HTTP_11
639                    && !parts
640                        .headers
641                        .get(http::header::CONNECTION)
642                        .and_then(|v| v.to_str().ok())
643                        .is_some_and(|v| v.eq_ignore_ascii_case("close")));
644            if keep {
645                self.checkin(key, s);
646            }
647        }
648        Ok(ClientResponse {
649            status: parts.status,
650            version: parts.version,
651            headers: parts.headers,
652            body: bytes,
653        })
654    }
655
656    fn checkout(&self, key: &PoolKey) -> Option<Sender> {
657        let mut pool = self.shared.pool.lock().unwrap_or_else(|e| e.into_inner());
658        let list = pool.get_mut(key)?;
659        list.retain(|i| !i.sender.is_closed());
660        // a shared HTTP/2 connection is lent out as a clone and stays
661        if let Some(idle) = list.iter_mut().find(|i| i.sender.is_h2()) {
662            idle.since = Instant::now();
663            return idle.sender.duplicate();
664        }
665        while let Some(idle) = list.pop() {
666            if !idle.sender.is_closed() && idle.since.elapsed() < self.idle_timeout {
667                return Some(idle.sender);
668            }
669        }
670        None
671    }
672
673    fn checkin(&self, key: PoolKey, sender: Sender) {
674        if self.idle_timeout.is_zero() || sender.is_closed() {
675            return;
676        }
677        let mut pool = self.shared.pool.lock().unwrap_or_else(|e| e.into_inner());
678        let list = pool.entry(key).or_default();
679        list.retain(|i| !i.sender.is_closed() && i.since.elapsed() < self.idle_timeout);
680        // one HTTP/2 connection per host is enough; a racing second one is dropped
681        if sender.is_h2() && list.iter().any(|i| i.sender.is_h2()) {
682            return;
683        }
684        if list.len() < self.max_idle_per_host {
685            list.push(Idle {
686                sender,
687                since: Instant::now(),
688            });
689        }
690    }
691
692    async fn connect(&self, key: &PoolKey) -> Result<Sender, ClientError> {
693        #[cfg(not(feature = "client-tls"))]
694        if key.https {
695            return Err(ClientError::Tls(
696                "https needs the `client-tls` feature of ullun-api".into(),
697            ));
698        }
699        let connect = async {
700            let addrs: Vec<SocketAddr> = tokio::net::lookup_host((key.host.as_str(), key.port))
701                .await
702                .map_err(|e| ClientError::Connect(e.to_string()))?
703                .collect();
704            if addrs.is_empty() {
705                return Err(ClientError::Connect("name resolution failed".into()));
706            }
707            // every answer must be public, not just the one used, and we connect
708            // to the vetted address itself (no second lookup to rebind)
709            if self.public_only {
710                if let Some(bad) = addrs
711                    .iter()
712                    .find(|a| !crate::security::is_public_ip(a.ip()))
713                {
714                    return Err(ClientError::Blocked(bad.ip()));
715                }
716            }
717            // try the addresses in order: "localhost" may list ::1 first
718            let mut last = String::new();
719            let mut connected = None;
720            for addr in addrs {
721                match TcpStream::connect(addr).await {
722                    Ok(tcp) => {
723                        connected = Some(tcp);
724                        break;
725                    }
726                    Err(e) => last = e.to_string(),
727                }
728            }
729            let tcp = connected.ok_or(ClientError::Connect(last))?;
730            let _ = tcp.set_nodelay(true);
731            let (io, h2): (Box<dyn Io>, bool) = if key.https {
732                self.tls(tcp, &key.host).await?
733            } else {
734                (Box::new(tcp), self.h2_prior_knowledge)
735            };
736            if h2 {
737                let (sender, conn) = hyper::client::conn::http2::Builder::new(TokioExecutor::new())
738                    .timer(TokioTimer::new())
739                    // notice peers that vanished without closing
740                    .keep_alive_interval(Duration::from_secs(30))
741                    .keep_alive_timeout(Duration::from_secs(20))
742                    .handshake(TokioIo::new(io))
743                    .await
744                    .map_err(|e| ClientError::Protocol(e.to_string()))?;
745                tokio::spawn(async move {
746                    let _ = conn.await;
747                });
748                return Ok::<_, ClientError>(Sender::H2(sender));
749            }
750            let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
751                .await
752                .map_err(|e| ClientError::Protocol(e.to_string()))?;
753            tokio::spawn(async move {
754                let _ = conn.await;
755            });
756            Ok::<_, ClientError>(Sender::H1(sender))
757        };
758        tokio::time::timeout(self.connect_timeout, connect)
759            .await
760            .map_err(|_| ClientError::Timeout)?
761    }
762
763    #[cfg(feature = "client-tls")]
764    async fn tls(&self, tcp: TcpStream, host: &str) -> Result<(Box<dyn Io>, bool), ClientError> {
765        let name = tokio_rustls::rustls::pki_types::ServerName::try_from(host.to_string())
766            .map_err(|_| ClientError::Tls(format!("invalid server name {host}")))?;
767        let connector = if self.http2 {
768            &self.shared.tls
769        } else {
770            &self.shared.tls_h1
771        };
772        let stream = connector
773            .connect(name, tcp)
774            .await
775            .map_err(|e| ClientError::Tls(e.to_string()))?;
776        let h2 = stream.get_ref().1.alpn_protocol() == Some(b"h2");
777        Ok((Box::new(stream), h2))
778    }
779
780    #[cfg(not(feature = "client-tls"))]
781    async fn tls(&self, _tcp: TcpStream, _host: &str) -> Result<(Box<dyn Io>, bool), ClientError> {
782        Err(ClientError::Tls(
783            "https needs the `client-tls` feature of ullun-api".into(),
784        ))
785    }
786}
787
788#[cfg(feature = "client-tls")]
789fn tls_connectors(
790    extra_pem: &[&[u8]],
791) -> Result<(tokio_rustls::TlsConnector, tokio_rustls::TlsConnector), ClientError> {
792    use tokio_rustls::rustls::pki_types::pem::PemObject;
793    use tokio_rustls::rustls::pki_types::CertificateDer;
794    use tokio_rustls::rustls::{ClientConfig, RootCertStore};
795
796    let mut roots = RootCertStore::empty();
797    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
798    for pem in extra_pem {
799        let mut added = 0;
800        for cert in CertificateDer::pem_slice_iter(pem) {
801            let cert = cert.map_err(|e| ClientError::Tls(e.to_string()))?;
802            roots
803                .add(cert)
804                .map_err(|e| ClientError::Tls(e.to_string()))?;
805            added += 1;
806        }
807        if added == 0 {
808            return Err(ClientError::Tls("no certificate in the PEM data".into()));
809        }
810    }
811    let mut config = ClientConfig::builder_with_provider(Arc::new(
812        tokio_rustls::rustls::crypto::ring::default_provider(),
813    ))
814    .with_safe_default_protocol_versions()
815    .map_err(|e| ClientError::Tls(e.to_string()))?
816    .with_root_certificates(roots)
817    .with_no_client_auth();
818    let mut http1_only = config.clone();
819    http1_only.alpn_protocols = vec![b"http/1.1".to_vec()];
820    config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
821    Ok((
822        tokio_rustls::TlsConnector::from(Arc::new(config)),
823        tokio_rustls::TlsConnector::from(Arc::new(http1_only)),
824    ))
825}
826
827/// Headers HTTP/2 forbids (they describe the HTTP/1 connection)
828fn is_connection_specific(name: &HeaderName) -> bool {
829    matches!(
830        name.as_str(),
831        "connection" | "keep-alive" | "proxy-connection" | "transfer-encoding" | "upgrade" | "host"
832    )
833}
834
835async fn read_limited(mut body: Incoming, max: usize) -> Result<Bytes, ClientError> {
836    use bytes::BufMut;
837    use hyper::body::Body;
838    if body.size_hint().lower() > max as u64 {
839        return Err(ClientError::TooLarge(max));
840    }
841    let mut out = bytes::BytesMut::new();
842    while let Some(frame) = body.frame().await {
843        let frame = frame.map_err(|e| ClientError::Protocol(e.to_string()))?;
844        if let Some(data) = frame.data_ref() {
845            if out.len() + data.len() > max {
846                return Err(ClientError::TooLarge(max));
847            }
848            out.put_slice(data);
849        }
850    }
851    Ok(out.freeze())
852}
853
854/// (https, host, port) of an absolute `http(s)` URL
855fn target(uri: &Uri) -> Result<(bool, String, u16), ClientError> {
856    let https = match uri.scheme_str() {
857        Some("http") => false,
858        Some("https") => true,
859        _ => return Err(ClientError::InvalidUrl(uri.to_string())),
860    };
861    let host = uri
862        .host()
863        .filter(|h| !h.is_empty())
864        .ok_or_else(|| ClientError::InvalidUrl(uri.to_string()))?;
865    // IPv6 literals come bracketed
866    let host = host
867        .trim_start_matches('[')
868        .trim_end_matches(']')
869        .to_string();
870    let port = uri.port_u16().unwrap_or(if https { 443 } else { 80 });
871    Ok((https, host, port))
872}
873
874fn origin_of(url: &str) -> Result<(bool, String, u16), ClientError> {
875    let uri: Uri = url
876        .parse()
877        .map_err(|_| ClientError::InvalidUrl(url.to_string()))?;
878    target(&uri)
879}
880
881/// Absolute URL for a (possibly relative) `Location` value
882fn resolve_location(base: &Uri, location: &str) -> Option<String> {
883    if location.starts_with("http://") || location.starts_with("https://") {
884        return Some(location.to_string());
885    }
886    let scheme = base.scheme_str()?;
887    let authority = base.authority()?;
888    if location.starts_with('/') {
889        return Some(format!("{scheme}://{authority}{location}"));
890    }
891    // relative to the directory of the current path
892    let dir = base.path().rsplit_once('/').map(|(d, _)| d).unwrap_or("");
893    Some(format!("{scheme}://{authority}{dir}/{location}"))
894}
895
896/// What the server answered
897#[derive(Debug, Clone)]
898pub struct ClientResponse {
899    status: StatusCode,
900    version: http::Version,
901    headers: HeaderMap,
902    body: Bytes,
903}
904
905impl ClientResponse {
906    /// The HTTP version the response arrived with (`HTTP_2` on an HTTP/2 connection)
907    pub fn version(&self) -> http::Version {
908        self.version
909    }
910
911    pub fn status(&self) -> u16 {
912        self.status.as_u16()
913    }
914
915    pub fn is_success(&self) -> bool {
916        self.status.is_success()
917    }
918
919    pub fn headers(&self) -> &HeaderMap {
920        &self.headers
921    }
922
923    /// Header value, empty when absent or not text
924    pub fn header(&self, name: &str) -> &str {
925        self.headers
926            .get(name)
927            .and_then(|v| v.to_str().ok())
928            .unwrap_or("")
929    }
930
931    pub fn bytes(&self) -> &Bytes {
932        &self.body
933    }
934
935    pub fn text(&self) -> Result<String, ClientError> {
936        String::from_utf8(self.body.to_vec()).map_err(|e| ClientError::Decode(e.to_string()))
937    }
938
939    pub fn json<T: DeserializeOwned>(&self) -> Result<T, ClientError> {
940        serde_json::from_slice(&self.body).map_err(|e| ClientError::Decode(e.to_string()))
941    }
942
943    /// Seconds from a numeric `Retry-After` header
944    fn retry_after(&self) -> Option<Duration> {
945        self.header("retry-after")
946            .trim()
947            .parse::<u64>()
948            .ok()
949            .map(Duration::from_secs)
950    }
951}