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 trace(self, incoming: &crate::request::Request) -> Self {
364 let Some(ctx) = incoming.trace() else {
365 return self;
366 };
367 let this = self.header("traceparent", &ctx.traceparent());
368 match &ctx.tracestate {
369 Some(state) => this.header("tracestate", state),
370 None => this,
371 }
372 }
373
374 pub fn timeout(mut self, timeout: Duration) -> Self {
376 self.timeout = Some(timeout);
377 self
378 }
379
380 pub async fn send(self) -> Result<ClientResponse, ClientError> {
381 if let Some(e) = self.error {
382 return Err(e);
383 }
384 let c = &self.client;
385 let timeout = self.timeout.unwrap_or(c.timeout);
386 let repeatable = is_repeatable(&self.method, &self.headers);
387 let mut attempt = 0;
388 loop {
389 let outcome = match tokio::time::timeout(timeout, c.run(&self)).await {
390 Ok(r) => r,
391 Err(_) => Err(ClientError::Timeout),
392 };
393 let retry_after = match &outcome {
394 Ok(res) if retryable_status(res.status) => Some(res.retry_after()),
395 Ok(_) => None,
396 Err(e) if retryable_error(e) => Some(None),
397 Err(_) => None,
398 };
399 match retry_after {
400 Some(hint) if repeatable && attempt < c.retries => {
401 let wait = hint.unwrap_or_else(|| c.delay(attempt));
402 tokio::time::sleep(wait.min(Duration::from_secs(10))).await;
404 attempt += 1;
405 }
406 _ => return outcome,
407 }
408 }
409 }
410}
411
412fn is_repeatable(method: &Method, headers: &HeaderMap) -> bool {
413 matches!(
414 *method,
415 Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
416 ) || headers.contains_key("idempotency-key")
417}
418
419fn retryable_status(status: StatusCode) -> bool {
420 matches!(status.as_u16(), 429 | 502 | 503 | 504)
421}
422
423fn retryable_error(e: &ClientError) -> bool {
424 matches!(
425 e,
426 ClientError::Connect(_) | ClientError::Timeout | ClientError::Protocol(_)
427 )
428}
429
430impl HttpClient {
431 fn delay(&self, attempt: u32) -> Duration {
433 let base = self.backoff.saturating_mul(1 << attempt.min(8));
434 let jitter = crate::security::random_bytes(1)
435 .ok()
436 .and_then(|b| b.first().copied())
437 .unwrap_or(0) as f64
438 / 255.0
439 * 0.5;
440 base.mul_f64(1.0 + jitter)
441 }
442
443 async fn run(&self, req: &RequestBuilder) -> Result<ClientResponse, ClientError> {
445 let mut url = req.url.clone();
446 let mut method = req.method.clone();
447 let mut body = req.body.clone();
448 let mut headers = req.headers.clone();
449 let origin = origin_of(&req.url)?;
450 for _ in 0..=self.max_redirects {
451 let uri: Uri = url
452 .parse()
453 .map_err(|_| ClientError::InvalidUrl(url.clone()))?;
454 let res = self.exchange(&uri, &method, &headers, &body).await?;
455 let status = res.status;
456 let location = res
457 .headers
458 .get(http::header::LOCATION)
459 .and_then(|v| v.to_str().ok());
460 if self.max_redirects == 0 || !status.is_redirection() || location.is_none() {
461 return Ok(res);
462 }
463 let next = resolve_location(&uri, location.unwrap_or_default())
464 .ok_or_else(|| ClientError::Protocol("bad redirect location".into()))?;
465 if matches!(status.as_u16(), 301..=303) && method != Method::HEAD {
467 method = Method::GET;
468 body = Bytes::new();
469 headers.remove(http::header::CONTENT_TYPE);
470 headers.remove(http::header::CONTENT_LENGTH);
471 }
472 if origin_of(&next)? != origin {
473 headers.remove(http::header::AUTHORIZATION);
475 headers.remove(http::header::COOKIE);
476 }
477 url = next;
478 }
479 Err(ClientError::TooManyRedirects)
480 }
481
482 async fn exchange(
483 &self,
484 uri: &Uri,
485 method: &Method,
486 extra: &HeaderMap,
487 body: &Bytes,
488 ) -> Result<ClientResponse, ClientError> {
489 let (https, host, port) = target(uri)?;
490 let key = PoolKey {
491 https,
492 host: host.clone(),
493 port,
494 };
495 let path = uri
496 .path_and_query()
497 .map(|p| p.as_str())
498 .unwrap_or("/")
499 .to_string();
500
501 let build = || -> Result<http::Request<Full<Bytes>>, ClientError> {
502 let mut b = http::Request::builder().method(method.clone()).uri(&path);
503 let h = b.headers_mut().expect("builder has headers");
504 let host_value = if (https && port == 443) || (!https && port == 80) {
505 host.clone()
506 } else {
507 format!("{host}:{port}")
508 };
509 h.insert(
510 http::header::HOST,
511 HeaderValue::from_str(&host_value)
512 .map_err(|_| ClientError::InvalidUrl(uri.to_string()))?,
513 );
514 if let Ok(agent) = HeaderValue::from_str(&self.user_agent) {
515 h.insert(http::header::USER_AGENT, agent);
516 }
517 for (n, v) in self.headers.iter().chain(extra.iter()) {
518 h.insert(n.clone(), v.clone());
519 }
520 if !body.is_empty() || matches!(*method, Method::POST | Method::PUT | Method::PATCH) {
521 h.insert(http::header::CONTENT_LENGTH, body.len().into());
522 }
523 b.body(Full::new(body.clone()))
524 .map_err(|e| ClientError::Protocol(e.to_string()))
525 };
526
527 let mut sender = self.checkout(&key);
530 let mut response = None;
531 if let Some(s) = sender.as_mut() {
532 match s.send_request(build()?).await {
533 Ok(r) => response = Some(r),
534 Err(_) => sender = None,
535 }
536 }
537 let response = match response {
538 Some(r) => r,
539 None => {
540 let mut fresh = self.connect(&key).await?;
541 let r = fresh
542 .send_request(build()?)
543 .await
544 .map_err(|e| ClientError::Protocol(e.to_string()))?;
545 sender = Some(fresh);
546 r
547 }
548 };
549
550 let (parts, incoming) = response.into_parts();
551 let bytes = read_limited(incoming, self.max_response).await?;
552 let keep = parts.version == http::Version::HTTP_11
553 && !parts
554 .headers
555 .get(http::header::CONNECTION)
556 .and_then(|v| v.to_str().ok())
557 .is_some_and(|v| v.eq_ignore_ascii_case("close"));
558 if let (true, Some(s)) = (keep, sender) {
559 self.checkin(key, s);
560 }
561 Ok(ClientResponse {
562 status: parts.status,
563 headers: parts.headers,
564 body: bytes,
565 })
566 }
567
568 fn checkout(&self, key: &PoolKey) -> Option<Sender> {
569 let mut pool = self.shared.pool.lock().unwrap_or_else(|e| e.into_inner());
570 let list = pool.get_mut(key)?;
571 while let Some(idle) = list.pop() {
572 if !idle.sender.is_closed() && idle.since.elapsed() < self.idle_timeout {
573 return Some(idle.sender);
574 }
575 }
576 None
577 }
578
579 fn checkin(&self, key: PoolKey, sender: Sender) {
580 if self.idle_timeout.is_zero() || sender.is_closed() {
581 return;
582 }
583 let mut pool = self.shared.pool.lock().unwrap_or_else(|e| e.into_inner());
584 let list = pool.entry(key).or_default();
585 list.retain(|i| !i.sender.is_closed() && i.since.elapsed() < self.idle_timeout);
586 if list.len() < self.max_idle_per_host {
587 list.push(Idle {
588 sender,
589 since: Instant::now(),
590 });
591 }
592 }
593
594 async fn connect(&self, key: &PoolKey) -> Result<Sender, ClientError> {
595 #[cfg(not(feature = "client-tls"))]
596 if key.https {
597 return Err(ClientError::Tls(
598 "https needs the `client-tls` feature of ullun-api".into(),
599 ));
600 }
601 let connect = async {
602 let addrs: Vec<SocketAddr> = tokio::net::lookup_host((key.host.as_str(), key.port))
603 .await
604 .map_err(|e| ClientError::Connect(e.to_string()))?
605 .collect();
606 if addrs.is_empty() {
607 return Err(ClientError::Connect("name resolution failed".into()));
608 }
609 if self.public_only {
612 if let Some(bad) = addrs
613 .iter()
614 .find(|a| !crate::security::is_public_ip(a.ip()))
615 {
616 return Err(ClientError::Blocked(bad.ip()));
617 }
618 }
619 let mut last = String::new();
621 let mut connected = None;
622 for addr in addrs {
623 match TcpStream::connect(addr).await {
624 Ok(tcp) => {
625 connected = Some(tcp);
626 break;
627 }
628 Err(e) => last = e.to_string(),
629 }
630 }
631 let tcp = connected.ok_or(ClientError::Connect(last))?;
632 let _ = tcp.set_nodelay(true);
633 let io: Box<dyn Io> = if key.https {
634 self.tls(tcp, &key.host).await?
635 } else {
636 Box::new(tcp)
637 };
638 let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
639 .await
640 .map_err(|e| ClientError::Protocol(e.to_string()))?;
641 tokio::spawn(async move {
642 let _ = conn.await;
643 });
644 Ok::<_, ClientError>(sender)
645 };
646 tokio::time::timeout(self.connect_timeout, connect)
647 .await
648 .map_err(|_| ClientError::Timeout)?
649 }
650
651 #[cfg(feature = "client-tls")]
652 async fn tls(&self, tcp: TcpStream, host: &str) -> Result<Box<dyn Io>, ClientError> {
653 let name = tokio_rustls::rustls::pki_types::ServerName::try_from(host.to_string())
654 .map_err(|_| ClientError::Tls(format!("invalid server name {host}")))?;
655 let stream = self
656 .shared
657 .tls
658 .connect(name, tcp)
659 .await
660 .map_err(|e| ClientError::Tls(e.to_string()))?;
661 Ok(Box::new(stream))
662 }
663
664 #[cfg(not(feature = "client-tls"))]
665 async fn tls(&self, _tcp: TcpStream, _host: &str) -> Result<Box<dyn Io>, ClientError> {
666 Err(ClientError::Tls(
667 "https needs the `client-tls` feature of ullun-api".into(),
668 ))
669 }
670}
671
672#[cfg(feature = "client-tls")]
673fn tls_connector(extra_pem: &[&[u8]]) -> Result<tokio_rustls::TlsConnector, ClientError> {
674 use tokio_rustls::rustls::pki_types::pem::PemObject;
675 use tokio_rustls::rustls::pki_types::CertificateDer;
676 use tokio_rustls::rustls::{ClientConfig, RootCertStore};
677
678 let mut roots = RootCertStore::empty();
679 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
680 for pem in extra_pem {
681 let mut added = 0;
682 for cert in CertificateDer::pem_slice_iter(pem) {
683 let cert = cert.map_err(|e| ClientError::Tls(e.to_string()))?;
684 roots
685 .add(cert)
686 .map_err(|e| ClientError::Tls(e.to_string()))?;
687 added += 1;
688 }
689 if added == 0 {
690 return Err(ClientError::Tls("no certificate in the PEM data".into()));
691 }
692 }
693 let mut config = ClientConfig::builder_with_provider(Arc::new(
694 tokio_rustls::rustls::crypto::ring::default_provider(),
695 ))
696 .with_safe_default_protocol_versions()
697 .map_err(|e| ClientError::Tls(e.to_string()))?
698 .with_root_certificates(roots)
699 .with_no_client_auth();
700 config.alpn_protocols = vec![b"http/1.1".to_vec()];
701 Ok(tokio_rustls::TlsConnector::from(Arc::new(config)))
702}
703
704async fn read_limited(mut body: Incoming, max: usize) -> Result<Bytes, ClientError> {
705 use bytes::BufMut;
706 use hyper::body::Body;
707 if body.size_hint().lower() > max as u64 {
708 return Err(ClientError::TooLarge(max));
709 }
710 let mut out = bytes::BytesMut::new();
711 while let Some(frame) = body.frame().await {
712 let frame = frame.map_err(|e| ClientError::Protocol(e.to_string()))?;
713 if let Some(data) = frame.data_ref() {
714 if out.len() + data.len() > max {
715 return Err(ClientError::TooLarge(max));
716 }
717 out.put_slice(data);
718 }
719 }
720 Ok(out.freeze())
721}
722
723fn target(uri: &Uri) -> Result<(bool, String, u16), ClientError> {
725 let https = match uri.scheme_str() {
726 Some("http") => false,
727 Some("https") => true,
728 _ => return Err(ClientError::InvalidUrl(uri.to_string())),
729 };
730 let host = uri
731 .host()
732 .filter(|h| !h.is_empty())
733 .ok_or_else(|| ClientError::InvalidUrl(uri.to_string()))?;
734 let host = host
736 .trim_start_matches('[')
737 .trim_end_matches(']')
738 .to_string();
739 let port = uri.port_u16().unwrap_or(if https { 443 } else { 80 });
740 Ok((https, host, port))
741}
742
743fn origin_of(url: &str) -> Result<(bool, String, u16), ClientError> {
744 let uri: Uri = url
745 .parse()
746 .map_err(|_| ClientError::InvalidUrl(url.to_string()))?;
747 target(&uri)
748}
749
750fn resolve_location(base: &Uri, location: &str) -> Option<String> {
752 if location.starts_with("http://") || location.starts_with("https://") {
753 return Some(location.to_string());
754 }
755 let scheme = base.scheme_str()?;
756 let authority = base.authority()?;
757 if location.starts_with('/') {
758 return Some(format!("{scheme}://{authority}{location}"));
759 }
760 let dir = base.path().rsplit_once('/').map(|(d, _)| d).unwrap_or("");
762 Some(format!("{scheme}://{authority}{dir}/{location}"))
763}
764
765#[derive(Debug, Clone)]
767pub struct ClientResponse {
768 status: StatusCode,
769 headers: HeaderMap,
770 body: Bytes,
771}
772
773impl ClientResponse {
774 pub fn status(&self) -> u16 {
775 self.status.as_u16()
776 }
777
778 pub fn is_success(&self) -> bool {
779 self.status.is_success()
780 }
781
782 pub fn headers(&self) -> &HeaderMap {
783 &self.headers
784 }
785
786 pub fn header(&self, name: &str) -> &str {
788 self.headers
789 .get(name)
790 .and_then(|v| v.to_str().ok())
791 .unwrap_or("")
792 }
793
794 pub fn bytes(&self) -> &Bytes {
795 &self.body
796 }
797
798 pub fn text(&self) -> Result<String, ClientError> {
799 String::from_utf8(self.body.to_vec()).map_err(|e| ClientError::Decode(e.to_string()))
800 }
801
802 pub fn json<T: DeserializeOwned>(&self) -> Result<T, ClientError> {
803 serde_json::from_slice(&self.body).map_err(|e| ClientError::Decode(e.to_string()))
804 }
805
806 fn retry_after(&self) -> Option<Duration> {
808 self.header("retry-after")
809 .trim()
810 .parse::<u64>()
811 .ok()
812 .map(Duration::from_secs)
813 }
814}