use std::{num::NonZeroUsize, time::Duration};
use rama_core::Layer;
use rama_core::error::{BoxError, BoxErrorExt as _};
use rama_core::extensions::ExtensionsRef;
use rama_net::address::{HostWithOptPort, ProxyAddress};
use rama_net::client::pool::{ConnID, MultiplexPool, MuxSelection, PooledConnector, ReqToConnID};
use rama_net::client::{ConnectRequest, ConnectorService, ConnectorTarget, ProxyRoute};
use rama_net::{AuthorityInputExt, Protocol, ProtocolInputExt};
use super::{BindBodyToConnLayer, BindBodyToConnector};
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct BasicHttpConnIdentifier;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BasicHttpConId {
pub protocol: Option<Protocol>,
pub authority: HostWithOptPort,
pub proxy_address: Option<ProxyAddress>,
pub connector_target: Option<ConnectorTarget>,
}
impl ConnID for BasicHttpConId {
#[cfg(feature = "opentelemetry")]
fn attributes(&self) -> impl Iterator<Item = rama_core::telemetry::opentelemetry::KeyValue> {
self.protocol
.as_ref()
.map(|protocol| {
rama_core::telemetry::opentelemetry::KeyValue::new("protocol", protocol.to_string())
})
.into_iter()
.chain([rama_core::telemetry::opentelemetry::KeyValue::new(
"authority",
self.authority.to_string(),
)])
}
}
impl<Input> ReqToConnID<Input> for BasicHttpConnIdentifier
where
Input: AuthorityInputExt + ExtensionsRef + ProtocolInputExt,
{
type ID = BasicHttpConId;
fn id(&self, req: &Input) -> Result<Self::ID, BoxError> {
let authority = req
.authority()
.ok_or_else(|| BoxError::from_static_str("no authority found in connection input"))?;
let protocol = req.protocol().cloned();
Ok(BasicHttpConId {
protocol,
authority,
proxy_address: req
.extensions()
.get_ref::<ProxyRoute>()
.and_then(ProxyRoute::proxy_address)
.cloned(),
connector_target: req.extensions().get_ref().cloned(),
})
}
}
pub type HttpPooledConnector<S> = BindBodyToConnector<
PooledConnector<
S,
MultiplexPool<<S as ConnectorService<ConnectRequest>>::Connection, BasicHttpConId>,
BasicHttpConnIdentifier,
>,
>;
#[derive(Debug, Clone)]
pub struct HttpPooledConnectorConfig {
pub max_total: usize,
pub max_concurrent_streams: usize,
pub selection: MuxSelection,
pub idle_timeout: Option<Duration>,
pub wait_for_pool_timeout: Option<Duration>,
}
const DEFAULT_MAX_TOTAL: NonZeroUsize = NonZeroUsize::new(50).unwrap();
const DEFAULT_MAX_CONCURRENT_STREAMS: NonZeroUsize = NonZeroUsize::new(100).unwrap();
impl Default for HttpPooledConnectorConfig {
fn default() -> Self {
Self {
max_total: DEFAULT_MAX_TOTAL.get(),
max_concurrent_streams: DEFAULT_MAX_CONCURRENT_STREAMS.get(),
selection: MuxSelection::default(),
idle_timeout: Some(Duration::from_secs(300)),
wait_for_pool_timeout: Some(Duration::from_secs(120)),
}
}
}
impl HttpPooledConnectorConfig {
pub fn build_default_connector<S>(inner: S) -> HttpPooledConnector<S>
where
S: ConnectorService<ConnectRequest>,
{
let config = Self::default();
let pool = MultiplexPool::new(DEFAULT_MAX_CONCURRENT_STREAMS, DEFAULT_MAX_TOTAL)
.with_selection(config.selection)
.maybe_with_idle_timeout(config.idle_timeout);
let connector = PooledConnector::new(inner, pool, BasicHttpConnIdentifier)
.maybe_with_wait_for_pool_timeout(config.wait_for_pool_timeout);
BindBodyToConnLayer::new().into_layer(connector)
}
pub fn try_build_connector<S>(self, inner: S) -> Result<HttpPooledConnector<S>, BoxError>
where
S: ConnectorService<ConnectRequest>,
{
let pool = MultiplexPool::try_new(self.max_concurrent_streams, self.max_total)?
.with_selection(self.selection)
.maybe_with_idle_timeout(self.idle_timeout);
let connector = PooledConnector::new(inner, pool, BasicHttpConnIdentifier)
.maybe_with_wait_for_pool_timeout(self.wait_for_pool_timeout);
Ok(BindBodyToConnLayer::new().into_layer(connector))
}
}
#[cfg(test)]
mod tests {
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use rama_core::error::{BoxError, BoxErrorExt as _};
use rama_core::extensions::ExtensionsRef;
use rama_core::rt::Executor;
use rama_core::service::service_fn;
use rama_core::{Layer, Service, ServiceInput};
use rama_http_types::body::util::BodyExt as _;
use rama_http_types::{Body, HeaderValue, Request, Response, StatusCode, Version};
use rama_net::Protocol;
use rama_net::address::{HostWithPort, ProxyAddress};
use rama_net::client::pool::{MultiplexPool, PooledConnector, ReqToConnID};
use rama_net::client::{
ConnectRequest, ConnectionError, ConnectionErrorKind, ConnectorService,
EstablishedClientConnection, ProxyRoute, ProxyRoutes, ProxyRoutesConnector,
};
use rama_net::conn::{ConnectionHealth, ConnectionHealthWatcher};
use rama_net::test_utils::client::MockConnectorService;
use rama_utils::octets::kib;
use tokio::time::sleep;
use super::{BasicHttpConnIdentifier, HttpPooledConnector, HttpPooledConnectorConfig};
use crate::client::{HttpConnectRequestAdapter, HttpConnectorLayer};
use crate::server::HttpServer;
fn create_test_request(version: Version) -> Request {
Request::builder()
.uri("https://www.example.com")
.version(version)
.body(Body::from("a random request body"))
.unwrap()
}
fn build_test_connector<S>(
config: HttpPooledConnectorConfig,
inner: S,
) -> HttpConnectRequestAdapter<HttpPooledConnector<S>>
where
S: ConnectorService<ConnectRequest>,
{
HttpConnectRequestAdapter::new(config.try_build_connector(inner).unwrap())
}
#[test]
fn connection_id_uses_only_the_selected_proxy_route() {
let request = Request::builder()
.uri("https://example.com")
.body(())
.unwrap();
request.extensions().insert(ProxyRoute::Direct);
let direct_id = BasicHttpConnIdentifier.id(&request).unwrap();
assert_eq!(direct_id.proxy_address, None);
let proxy_address = ProxyAddress {
protocol: Some(Protocol::HTTP),
address: HostWithPort::example_domain_http(),
credential: None,
};
request
.extensions()
.insert(ProxyRoute::Proxy(proxy_address.clone()));
let proxied_id = BasicHttpConnIdentifier.id(&request).unwrap();
assert_eq!(proxied_id.proxy_address, Some(proxy_address));
}
fn proxy_route(name: &str) -> ProxyRoute {
ProxyRoute::Proxy(
format!("http://{name}.example:8080")
.parse::<ProxyAddress>()
.unwrap(),
)
}
#[tokio::test]
async fn route_order_checks_pool_per_selected_route() {
let attempts = Arc::new(parking_lot::Mutex::new(Vec::new()));
let inner = service_fn({
let attempts = attempts.clone();
move |input: ConnectRequest| {
let attempts = attempts.clone();
async move {
let route = input.extensions.get_ref::<ProxyRoute>().unwrap();
let name = route
.proxy_address()
.map(|proxy| proxy.address.host.to_string())
.unwrap_or_else(|| "DIRECT".to_owned());
attempts.lock().push(name.clone());
if name == "c.example" {
Ok(EstablishedClientConnection {
input,
conn: ServiceInput::new(()),
})
} else {
Err(ConnectionError::transport(
BoxError::from_static_str("route unavailable"),
ConnectionErrorKind::Unavailable,
))
}
}
}
});
let pool = MultiplexPool::try_new(10, 10).unwrap();
let pooled = PooledConnector::new(inner, pool, BasicHttpConnIdentifier);
let connector = ProxyRoutesConnector::new(pooled);
let first = ConnectRequest::new(HostWithPort::example_domain_https())
.with_application_protocol(Protocol::HTTPS);
first.extensions.insert(ProxyRoutes::new([
proxy_route("a"),
proxy_route("b"),
proxy_route("c"),
]));
let established = connector.serve(first).await.unwrap();
assert_eq!(
established
.input
.extensions
.get_ref::<ProxyRoute>()
.unwrap(),
&proxy_route("c")
);
drop(established.conn);
let second = ConnectRequest::new(HostWithPort::example_domain_https())
.with_application_protocol(Protocol::HTTPS);
second
.extensions
.insert(ProxyRoutes::new([proxy_route("a"), proxy_route("c")]));
drop(connector.serve(second).await.unwrap().conn);
let third = ConnectRequest::new(HostWithPort::example_domain_https())
.with_application_protocol(Protocol::HTTPS);
third.extensions.insert(proxy_route("c"));
drop(connector.serve(third).await.unwrap().conn);
assert_eq!(
attempts.lock().as_slice(),
["a.example", "b.example", "c.example", "a.example"]
);
}
fn tagging_mock_connector() -> impl ConnectorService<
ConnectRequest,
Connection: Service<Request, Output = Response, Error = BoxError> + ExtensionsRef,
> {
let conns = Arc::new(AtomicUsize::new(0));
HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
let conn_id = conns.fetch_add(1, Ordering::Relaxed);
let resps = Arc::new(AtomicUsize::new(0));
HttpServer::auto(Executor::default()).service(service_fn(move |_req: Request| {
let resps = resps.clone();
async move {
let resp_id = resps.fetch_add(1, Ordering::Relaxed);
let mut resp = Response::new(Body::from("ok"));
let headers = resp.headers_mut();
headers.insert("x-conn-id", HeaderValue::from(conn_id as u64));
headers.insert("x-resp-id", HeaderValue::from(resp_id as u64));
Ok::<_, Infallible>(resp)
}
}))
}))
}
fn conn_id(resp: &Response) -> u64 {
resp.headers()
.get("x-conn-id")
.unwrap()
.to_str()
.unwrap()
.parse()
.unwrap()
}
#[tokio::test]
async fn pool_keeps_h2_connection_in_use_until_response_body_consumed() {
let connector = build_test_connector(
HttpPooledConnectorConfig {
max_concurrent_streams: 1,
max_total: 4,
..Default::default()
},
tagging_mock_connector(),
);
let req = || create_test_request(Version::HTTP_2);
let est1 = connector.serve(req()).await.unwrap();
let resp1 = est1.conn.serve(req()).await.unwrap();
drop(est1);
let est2 = connector.serve(req()).await.unwrap();
let resp2 = est2.conn.serve(req()).await.unwrap();
assert_eq!(conn_id(&resp1), 0);
assert_eq!(
conn_id(&resp2),
1,
"second request must not reuse a connection whose response body is still in flight"
);
}
#[tokio::test]
async fn pool_keeps_h1_connection_in_use_until_response_body_consumed() {
let connector = build_test_connector(
HttpPooledConnectorConfig {
max_concurrent_streams: 1,
max_total: 4,
..Default::default()
},
tagging_mock_connector(),
);
let req = || create_test_request(Version::HTTP_11);
let est1 = connector.serve(req()).await.unwrap();
let resp1 = est1.conn.serve(req()).await.unwrap();
drop(est1);
let est2 = connector.serve(req()).await.unwrap();
let resp2 = est2.conn.serve(req()).await.unwrap();
assert_eq!(conn_id(&resp1), 0);
assert_eq!(
conn_id(&resp2),
1,
"h1: second request must not reuse a connection whose response body is still in flight"
);
}
#[tokio::test(start_paused = true)]
async fn pool_does_not_reuse_h1_connection_after_server_close() {
let conns = Arc::new(AtomicUsize::new(0));
let inner =
HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
let conn_id = conns.fetch_add(1, Ordering::Relaxed);
HttpServer::auto(Executor::default()).service(service_fn(
move |_req: Request| async move {
let mut resp = Response::new(Body::from("ok"));
let headers = resp.headers_mut();
headers.insert("x-conn-id", HeaderValue::from(conn_id as u64));
headers.insert("connection", HeaderValue::from_static("close"));
Ok::<_, Infallible>(resp)
},
))
}));
let connector = build_test_connector(
HttpPooledConnectorConfig {
max_total: 4,
..Default::default()
},
inner,
);
let req = || create_test_request(Version::HTTP_11);
let est1 = connector.serve(req()).await.unwrap();
let resp1 = est1.conn.serve(req()).await.unwrap();
drop(est1);
let id1 = conn_id(&resp1);
resp1.into_body().collect().await.unwrap();
sleep(Duration::from_millis(50)).await;
let est2 = connector.serve(req()).await.unwrap();
let resp2 = est2.conn.serve(req()).await.unwrap();
assert_eq!(id1, 0);
assert_ne!(
conn_id(&resp2),
id1,
"must not reuse an h1 connection the server closed"
);
}
#[tokio::test]
async fn pool_does_not_reuse_h1_connection_after_upgrade() {
let conns = Arc::new(AtomicUsize::new(0));
let inner =
HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
let conn_id = conns.fetch_add(1, Ordering::Relaxed);
HttpServer::auto(Executor::default()).service(service_fn(
move |req: Request| async move {
let is_upgrade = req.uri().path().is_some_and(|path| path == "/upgrade");
if is_upgrade {
let on_upgrade = rama_http::io::upgrade::handle_upgrade(&req);
_ = tokio::spawn(async move {
_ = on_upgrade.await;
});
}
let mut resp = if is_upgrade {
Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.header("connection", "upgrade")
.header("upgrade", "test")
.body(Body::empty())
.unwrap()
} else {
Response::new(Body::from("ok"))
};
resp.headers_mut()
.insert("x-conn-id", HeaderValue::from(conn_id as u64));
Ok::<_, Infallible>(resp)
},
))
}));
let connector = build_test_connector(HttpPooledConnectorConfig::default(), inner);
let upgrade_request = Request::builder()
.uri("https://www.example.com/upgrade")
.version(Version::HTTP_11)
.header("connection", "upgrade")
.header("upgrade", "test")
.body(Body::empty())
.unwrap();
let established = connector.serve(upgrade_request).await.unwrap();
let response = established.conn.serve(established.input).await.unwrap();
let first_conn_id = conn_id(&response);
assert_eq!(
established
.conn
.extensions()
.get_ref::<ConnectionHealthWatcher>()
.expect("pooled h1 connection health watcher")
.health(),
ConnectionHealth::Broken,
"an h1 upgrade must be marked broken before returning its response"
);
let on_upgrade = rama_http::io::upgrade::handle_upgrade(&response);
let body = response.into_body();
drop(body);
let request = create_test_request(Version::HTTP_11);
let established = connector.serve(request).await.unwrap();
let response = established.conn.serve(established.input).await.unwrap();
assert_ne!(
conn_id(&response),
first_conn_id,
"an upgraded h1 connection must be evicted before the next request"
);
drop(on_upgrade.await.unwrap());
}
#[tokio::test(start_paused = true)]
async fn pool_does_not_reuse_h1_connection_after_body_dropped_early() {
let conns = Arc::new(AtomicUsize::new(0));
let inner =
HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
let conn_id = conns.fetch_add(1, Ordering::Relaxed);
HttpServer::auto(Executor::default()).service(service_fn(
move |_req: Request| async move {
let mut resp = Response::new(Body::from(vec![0u8; kib(1024)]));
resp.headers_mut()
.insert("x-conn-id", HeaderValue::from(conn_id as u64));
Ok::<_, Infallible>(resp)
},
))
}));
let connector = build_test_connector(
HttpPooledConnectorConfig {
max_total: 4,
..Default::default()
},
inner,
);
let req = || create_test_request(Version::HTTP_11);
let est1 = connector.serve(req()).await.unwrap();
let resp1 = est1.conn.serve(req()).await.unwrap();
drop(est1);
let id1 = conn_id(&resp1);
drop(resp1);
sleep(Duration::from_millis(50)).await;
let est2 = connector.serve(req()).await.unwrap();
let resp2 = est2.conn.serve(req()).await.unwrap();
assert_eq!(id1, 0);
assert_ne!(
conn_id(&resp2),
id1,
"must not reuse an h1 connection whose response body was abandoned mid-stream"
);
}
#[tokio::test]
async fn pool_reuses_connection_after_body_consumed() {
let connector = build_test_connector(
HttpPooledConnectorConfig {
max_concurrent_streams: 1,
max_total: 4,
..Default::default()
},
tagging_mock_connector(),
);
let req = || create_test_request(Version::HTTP_2);
let resp1 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
assert_eq!(conn_id(&resp1), 0);
resp1.into_body().collect().await.unwrap();
let resp2 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
assert_eq!(
conn_id(&resp2),
0,
"connection must be reused once its response body is consumed"
);
}
#[tokio::test]
async fn pool_multiplexes_on_h2() {
let connector = build_test_connector(
HttpPooledConnectorConfig::default(),
tagging_mock_connector(),
);
let req = || create_test_request(Version::HTTP_2);
let resp1 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
let resp2 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
assert_eq!(conn_id(&resp1), 0);
assert_eq!(
conn_id(&resp2),
0,
"h2: a second in-flight request multiplexes onto the same connection"
);
}
#[tokio::test]
async fn pool_does_not_multiplex_on_h1() {
let connector = build_test_connector(
HttpPooledConnectorConfig::default(),
tagging_mock_connector(),
);
let req = || create_test_request(Version::HTTP_11);
let resp1 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
let resp2 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
assert_eq!(conn_id(&resp1), 0);
assert_eq!(
conn_id(&resp2),
1,
"h1 does not multiplex: a second in-flight request needs a new connection"
);
}
#[tokio::test]
async fn pool_respects_max_concurrent_streams() {
let connector = build_test_connector(
HttpPooledConnectorConfig {
max_concurrent_streams: 2,
max_total: 4,
..Default::default()
},
tagging_mock_connector(),
);
let req = || create_test_request(Version::HTTP_2);
let resp1 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
let resp2 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
let resp3 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
assert_eq!(conn_id(&resp1), 0);
assert_eq!(conn_id(&resp2), 0, "second request fits on connection 0");
assert_eq!(
conn_id(&resp3),
1,
"third request exceeds the per-connection limit, needs a new connection"
);
}
fn large_body_mock_connector() -> impl ConnectorService<
ConnectRequest,
Connection: Service<Request, Output = Response, Error = BoxError> + ExtensionsRef,
> {
let conns = Arc::new(AtomicUsize::new(0));
HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
let conn_id = conns.fetch_add(1, Ordering::Relaxed);
HttpServer::auto(Executor::default()).service(service_fn(
move |_req: Request| async move {
let mut resp = Response::new(Body::from(vec![0u8; kib(1024)]));
resp.headers_mut()
.insert("x-conn-id", HeaderValue::from(conn_id as u64));
Ok::<_, Infallible>(resp)
},
))
}))
}
#[tokio::test]
async fn pool_binds_connection_across_streaming_body() {
let connector = build_test_connector(
HttpPooledConnectorConfig {
max_concurrent_streams: 1,
max_total: 4,
..Default::default()
},
large_body_mock_connector(),
);
let req = || create_test_request(Version::HTTP_2);
let resp1 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
let id1 = conn_id(&resp1);
let mut body1 = resp1.into_body();
assert!(
body1.frame().await.is_some(),
"streaming body should yield at least one frame"
);
let resp2 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
assert_eq!(id1, 0);
assert_ne!(
conn_id(&resp2),
id1,
"a connection still streaming its response body must not be reused"
);
while let Some(frame) = body1.frame().await {
frame.unwrap();
}
let resp3 = connector
.serve(req())
.await
.unwrap()
.conn
.serve(req())
.await
.unwrap();
assert_eq!(
conn_id(&resp3),
id1,
"a connection is reused once its streaming body reaches end-of-stream"
);
}
}