ugi 0.2.1

Runtime-agnostic Rust request client with HTTP/1.1, HTTP/2, HTTP/3, H2C, WebSocket, SSE, and gRPC support
Documentation
use crate::dns::{DnsCache, DnsConfig};
#[cfg(feature = "h2")]
use crate::pool::PoolKey;
#[cfg(feature = "h2")]
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
#[cfg(feature = "h2")]
use std::time::Instant;

use crate::error::{Error, ErrorKind, Result};
use crate::pool::PoolConfig;
#[cfg(feature = "h2")]
use crate::request::ProtocolPolicy;
use crate::request::Request;
use crate::response::Response;

#[cfg(feature = "h2")]
mod connection;
#[cfg(feature = "h2")]
mod frame;
#[cfg(feature = "h2")]
mod transport;

#[derive(Default)]
pub(crate) struct ConnectionPool {
    #[cfg(feature = "h2")]
    connections: HashMap<PoolKey, Vec<connection::H2ClientConnection>>,
}

impl ConnectionPool {
    #[cfg(feature = "h2")]
    fn acquire(
        &mut self,
        key: &PoolKey,
        pool_config: PoolConfig,
    ) -> Option<connection::H2ClientConnection> {
        let now = Instant::now();
        let connections = self.connections.get_mut(key)?;
        connections.retain(|connection| !connection.should_evict(now, pool_config));
        let selected = connections
            .iter()
            .filter(|connection| connection.can_accept_new_stream())
            .min_by_key(|connection| connection.load())
            .cloned();
        if connections.is_empty() {
            self.connections.remove(key);
        }
        selected
    }

    #[cfg(feature = "h2")]
    fn insert(
        &mut self,
        key: PoolKey,
        connection: connection::H2ClientConnection,
        pool_config: PoolConfig,
    ) -> bool {
        if pool_config.max_idle_per_host == 0 {
            return false;
        }

        let now = Instant::now();
        let connections = self.connections.entry(key).or_default();
        connections.retain(|existing| !existing.should_evict(now, pool_config));
        if connections
            .iter()
            .any(|existing| existing.ptr_eq(&connection))
        {
            return true;
        }
        if connections.len() >= pool_config.max_idle_per_host {
            return false;
        }
        connections.push(connection);
        true
    }

    pub(crate) fn clear(&mut self) {
        #[cfg(feature = "h2")]
        {
            for connections in self.connections.values() {
                for connection in connections {
                    connection.close();
                }
            }
            self.connections.clear();
        }
    }
}

#[cfg(feature = "h2")]
pub(crate) async fn execute(
    request: Request,
    pool: Arc<Mutex<ConnectionPool>>,
    dns_cache: Arc<DnsCache>,
    dns_config: DnsConfig,
    local_addr: Option<SocketAddr>,
    pool_config: PoolConfig,
) -> Result<Response> {
    connection::execute(
        request,
        pool,
        dns_cache,
        dns_config,
        local_addr,
        pool_config,
    )
    .await
}

#[cfg(not(feature = "h2"))]
pub(crate) async fn execute(
    _request: Request,
    _pool: Arc<Mutex<ConnectionPool>>,
    _dns_cache: Arc<DnsCache>,
    _dns_config: DnsConfig,
    _local_addr: Option<SocketAddr>,
    _pool_config: PoolConfig,
) -> Result<Response> {
    Err(Error::new(
        ErrorKind::Transport,
        "http2 support requires the h2 feature",
    ))
}

#[cfg(feature = "h2")]
pub(crate) fn can_attempt_h2(request: &Request) -> bool {
    (request.url().scheme() == "https" && request.tls_config().validate_h2_alpn().is_ok())
        || (request.url().scheme() == "http"
            && (request.prior_knowledge_h2c()
                || matches!(
                    request.protocol_policy(),
                    ProtocolPolicy::Http2Only | ProtocolPolicy::PreferHttp2
                )))
}

#[cfg(not(feature = "h2"))]
pub(crate) fn can_attempt_h2(_request: &Request) -> bool {
    false
}

#[cfg(feature = "h2")]
pub(crate) fn clone_request_for_h2(request: &Request) -> Result<Request> {
    let body = request.body().try_clone().ok_or_else(|| {
        Error::new(
            ErrorKind::BodyAlreadyConsumed,
            "request body cannot be cloned for http2",
        )
    })?;

    // PreferHttp2 fallback relies on the original request remaining HTTP/1-safe.
    // Apply any ALPN override only to this HTTP/2 clone.
    let mut tls_config = request.tls_config().clone();
    if let Some(profile) = request.emulation_profile() {
        if let Some(fp) = profile.tls_fingerprint() {
            if !fp.alpn_protocols.is_empty() {
                let protocols = fp
                    .alpn_protocols
                    .iter()
                    .filter(|p| p.as_str() == "h2" || p.as_str() == "http/1.1")
                    .cloned()
                    .collect::<Vec<_>>();
                if !protocols.is_empty() {
                    tls_config = tls_config.alpn_protocols(protocols);
                }
            }
        }
    }

    Ok(Request::new(
        request.method(),
        request.url().clone(),
        request.headers().clone(),
        request.cookies().to_vec(),
        request.timeout_config(),
        request.protocol_policy(),
        request.retry_policy(),
        request.prior_knowledge_h2c(),
        request.progress_callback().cloned(),
        request.progress_config(),
        request.h2_keepalive_config(),
        tls_config,
        request.proxy_cloned(),
        request.compression_mode(),
        body,
        request.emulation_profile().cloned(),
    ))
}

#[cfg(not(feature = "h2"))]
pub(crate) fn clone_request_for_h2(_request: &Request) -> Result<Request> {
    Err(Error::new(
        ErrorKind::Transport,
        "http2 support requires the h2 feature",
    ))
}

#[cfg(all(test, feature = "h2", feature = "emulation"))]
mod tests {
    use super::clone_request_for_h2;
    use crate::Emulation;
    use crate::client::shared_http1_transport;
    use crate::request::{Method, ProtocolPolicy, RequestBuilder};

    #[test]
    fn clone_request_for_h2_applies_alpn_only_to_clone() {
        let original = RequestBuilder::new(shared_http1_transport(), Method::Get, "https://x.test")
            .emulation(Emulation::Chrome136)
            .build_request()
            .unwrap();

        // Original request stays HTTP/1-safe (fallback must remain possible).
        assert!(
            original
                .tls_config()
                .validate_http1_alpn(ProtocolPolicy::Auto)
                .is_ok()
        );

        let cloned = clone_request_for_h2(&original).unwrap();
        assert_eq!(
            cloned.tls_config().validate_h2_alpn().unwrap(),
            vec!["h2".to_owned(), "http/1.1".to_owned()]
        );
        assert!(
            cloned
                .tls_config()
                .validate_http1_alpn(ProtocolPolicy::Auto)
                .is_err()
        );
    }
}