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