1use 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#[derive(Debug)]
55#[non_exhaustive]
56pub enum ClientError {
57 InvalidUrl(String),
59 Connect(String),
61 Tls(String),
63 Blocked(IpAddr),
65 Timeout,
67 Protocol(String),
69 TooLarge(usize),
71 TooManyRedirects,
73 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#[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 pub fn timeout(mut self, timeout: Duration) -> Self {
176 self.timeout = timeout;
177 self
178 }
179
180 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
182 self.connect_timeout = timeout;
183 self
184 }
185
186 pub fn max_response_bytes(mut self, max: usize) -> Self {
188 self.max_response = max;
189 self
190 }
191
192 pub fn max_redirects(mut self, max: usize) -> Self {
194 self.max_redirects = max;
195 self
196 }
197
198 pub fn retries(mut self, retries: u32) -> Self {
200 self.retries = retries;
201 self
202 }
203
204 pub fn retry_backoff(mut self, base: Duration) -> Self {
206 self.backoff = base;
207 self
208 }
209
210 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 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 pub fn pool_idle_timeout(mut self, timeout: Duration) -> Self {
236 self.idle_timeout = timeout;
237 self
238 }
239
240 #[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
285pub 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 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 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 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 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 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 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 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 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 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 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
708fn 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 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
735fn 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 let dir = base.path().rsplit_once('/').map(|(d, _)| d).unwrap_or("");
747 Some(format!("{scheme}://{authority}{dir}/{location}"))
748}
749
750#[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 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 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}