Skip to main content

rama_http_backend/client/
pool.rs

1//! HTTP connection pool: connection identity and connector assembly.
2
3use std::{num::NonZeroUsize, time::Duration};
4
5use rama_core::Layer;
6use rama_core::error::{BoxError, BoxErrorExt as _};
7use rama_core::extensions::ExtensionsRef;
8use rama_net::address::{HostWithOptPort, ProxyAddress};
9use rama_net::client::pool::{ConnID, MultiplexPool, MuxSelection, PooledConnector, ReqToConnID};
10use rama_net::client::{ConnectRequest, ConnectorService, ConnectorTarget, ProxyRoute};
11use rama_net::{AuthorityInputExt, Protocol, ProtocolInputExt};
12
13use super::{BindBodyToConnLayer, BindBodyToConnector};
14
15#[derive(Clone, Debug, Default)]
16#[non_exhaustive]
17/// [`BasicHttpConnIdentifier`] can be used together with a [`Pool`](rama_net::client::pool::Pool)
18/// to create a basic http connection pool.
19pub struct BasicHttpConnIdentifier;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22/// Connection Identifier which will match inputs that have the exact same
23/// protocol, authority, proxy address and connector target
24pub struct BasicHttpConId {
25    pub protocol: Option<Protocol>,
26    pub authority: HostWithOptPort,
27    pub proxy_address: Option<ProxyAddress>,
28    pub connector_target: Option<ConnectorTarget>,
29}
30
31impl ConnID for BasicHttpConId {
32    #[cfg(feature = "opentelemetry")]
33    fn attributes(&self) -> impl Iterator<Item = rama_core::telemetry::opentelemetry::KeyValue> {
34        self.protocol
35            .as_ref()
36            .map(|protocol| {
37                rama_core::telemetry::opentelemetry::KeyValue::new("protocol", protocol.to_string())
38            })
39            .into_iter()
40            .chain([rama_core::telemetry::opentelemetry::KeyValue::new(
41                "authority",
42                self.authority.to_string(),
43            )])
44    }
45}
46
47impl<Input> ReqToConnID<Input> for BasicHttpConnIdentifier
48where
49    Input: AuthorityInputExt + ExtensionsRef + ProtocolInputExt,
50{
51    type ID = BasicHttpConId;
52
53    fn id(&self, req: &Input) -> Result<Self::ID, BoxError> {
54        let authority = req
55            .authority()
56            .ok_or_else(|| BoxError::from_static_str("no authority found in connection input"))?;
57        let protocol = req.protocol().cloned();
58
59        Ok(BasicHttpConId {
60            protocol,
61            authority,
62            proxy_address: req
63                .extensions()
64                .get_ref::<ProxyRoute>()
65                .and_then(ProxyRoute::proxy_address)
66                .cloned(),
67            connector_target: req.extensions().get_ref().cloned(),
68        })
69    }
70}
71
72/// Default HTTP pooled connector assembled by
73/// [`HttpPooledConnectorConfig::try_build_connector`].
74pub type HttpPooledConnector<S> = BindBodyToConnector<
75    PooledConnector<
76        S,
77        MultiplexPool<<S as ConnectorService<ConnectRequest>>::Connection, BasicHttpConId>,
78        BasicHttpConnIdentifier,
79    >,
80>;
81
82#[derive(Debug, Clone)]
83/// Config used to create a multiplexing http connection pool ([`MultiplexPool`]).
84///
85/// The per-connection concurrency comes from the connection's
86/// [`MaxConcurrency`](rama_net::conn::MaxConcurrency) extension (set by the http
87/// connectors: 1 for http/1, the stream capacity for http/2), clamped to
88/// `max_concurrent_streams` as an upper bound.
89pub struct HttpPooledConnectorConfig {
90    /// Set the max amount of connections that this connection pool will contain
91    ///
92    /// This is the sum of active connections and idle connections. When this limit
93    /// is hit idle connections will be replaced with new ones.
94    pub max_total: usize,
95    /// Upper bound on the concurrent requests a single connection may serve.
96    ///
97    /// Acts as a ceiling for each connection, each connection also figures
98    /// it's own max concurrency out by itself
99    pub max_concurrent_streams: usize,
100    /// How a connection is chosen among several that can serve a request.
101    pub selection: MuxSelection,
102    /// If connections have been idle (no active streams) for longer than this
103    /// timeout they are dropped. Only checked when a connection is requested.
104    pub idle_timeout: Option<Duration>,
105    /// How long to wait for the pool to hand out a connection before timing out.
106    pub wait_for_pool_timeout: Option<Duration>,
107}
108
109const DEFAULT_MAX_TOTAL: NonZeroUsize = NonZeroUsize::new(50).unwrap();
110const DEFAULT_MAX_CONCURRENT_STREAMS: NonZeroUsize = NonZeroUsize::new(100).unwrap();
111
112impl Default for HttpPooledConnectorConfig {
113    fn default() -> Self {
114        Self {
115            max_total: DEFAULT_MAX_TOTAL.get(),
116            max_concurrent_streams: DEFAULT_MAX_CONCURRENT_STREAMS.get(),
117            selection: MuxSelection::default(),
118            idle_timeout: Some(Duration::from_secs(300)),
119            wait_for_pool_timeout: Some(Duration::from_secs(120)),
120        }
121    }
122}
123
124impl HttpPooledConnectorConfig {
125    /// Build a pooled HTTP connector using Rama's known-valid default limits.
126    ///
127    /// Unlike [`Self::try_build_connector`], this constructor is infallible because
128    /// its connection and concurrency limits are non-zero constants owned by
129    /// Rama.
130    pub fn build_default_connector<S>(inner: S) -> HttpPooledConnector<S>
131    where
132        S: ConnectorService<ConnectRequest>,
133    {
134        let config = Self::default();
135        let pool = MultiplexPool::new(DEFAULT_MAX_CONCURRENT_STREAMS, DEFAULT_MAX_TOTAL)
136            .with_selection(config.selection)
137            .maybe_with_idle_timeout(config.idle_timeout);
138
139        let connector = PooledConnector::new(inner, pool, BasicHttpConnIdentifier)
140            .maybe_with_wait_for_pool_timeout(config.wait_for_pool_timeout);
141
142        BindBodyToConnLayer::new().into_layer(connector)
143    }
144
145    /// Build a pooled HTTP connector around `inner`.
146    ///
147    /// The connector only adds body binding and pool lookup. HTTP request
148    /// adaptation and proxy-route selection remain independently composable
149    /// services and can be layered around the returned connector when needed.
150    ///
151    /// The returned connector wraps each pooled connection in
152    /// [`BindBodyToConn`](super::BindBodyToConn), so the pool only frees/reuses a
153    /// connection once its response body has been consumed, not at response
154    /// headers.
155    ///
156    /// Warning: the connection returned by this pool should only be used for a single
157    /// request. Every request should go through the connector stack again, and will
158    /// receive a new or reused connection (maybe multiplexed) of its own.
159    pub fn try_build_connector<S>(self, inner: S) -> Result<HttpPooledConnector<S>, BoxError>
160    where
161        S: ConnectorService<ConnectRequest>,
162    {
163        let pool = MultiplexPool::try_new(self.max_concurrent_streams, self.max_total)?
164            .with_selection(self.selection)
165            .maybe_with_idle_timeout(self.idle_timeout);
166
167        let connector = PooledConnector::new(inner, pool, BasicHttpConnIdentifier)
168            .maybe_with_wait_for_pool_timeout(self.wait_for_pool_timeout);
169
170        Ok(BindBodyToConnLayer::new().into_layer(connector))
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use std::convert::Infallible;
177    use std::sync::Arc;
178    use std::sync::atomic::{AtomicUsize, Ordering};
179    use std::time::Duration;
180
181    use rama_core::error::{BoxError, BoxErrorExt as _};
182    use rama_core::extensions::ExtensionsRef;
183    use rama_core::rt::Executor;
184    use rama_core::service::service_fn;
185    use rama_core::{Layer, Service, ServiceInput};
186    use rama_http_types::body::util::BodyExt as _;
187    use rama_http_types::{Body, HeaderValue, Request, Response, StatusCode, Version};
188    use rama_net::Protocol;
189    use rama_net::address::{HostWithPort, ProxyAddress};
190    use rama_net::client::pool::{MultiplexPool, PooledConnector, ReqToConnID};
191    use rama_net::client::{
192        ConnectRequest, ConnectionError, ConnectionErrorKind, ConnectorService,
193        EstablishedClientConnection, ProxyRoute, ProxyRoutes, ProxyRoutesConnector,
194    };
195    use rama_net::conn::{ConnectionHealth, ConnectionHealthWatcher};
196    use rama_net::test_utils::client::MockConnectorService;
197    use rama_utils::octets::kib;
198    use tokio::time::sleep;
199
200    use super::{BasicHttpConnIdentifier, HttpPooledConnector, HttpPooledConnectorConfig};
201    use crate::client::{HttpConnectRequestAdapter, HttpConnectorLayer};
202    use crate::server::HttpServer;
203
204    fn create_test_request(version: Version) -> Request {
205        Request::builder()
206            .uri("https://www.example.com")
207            .version(version)
208            .body(Body::from("a random request body"))
209            .unwrap()
210    }
211
212    fn build_test_connector<S>(
213        config: HttpPooledConnectorConfig,
214        inner: S,
215    ) -> HttpConnectRequestAdapter<HttpPooledConnector<S>>
216    where
217        S: ConnectorService<ConnectRequest>,
218    {
219        HttpConnectRequestAdapter::new(config.try_build_connector(inner).unwrap())
220    }
221
222    #[test]
223    fn connection_id_uses_only_the_selected_proxy_route() {
224        let request = Request::builder()
225            .uri("https://example.com")
226            .body(())
227            .unwrap();
228
229        request.extensions().insert(ProxyRoute::Direct);
230        let direct_id = BasicHttpConnIdentifier.id(&request).unwrap();
231        assert_eq!(direct_id.proxy_address, None);
232
233        let proxy_address = ProxyAddress {
234            protocol: Some(Protocol::HTTP),
235            address: HostWithPort::example_domain_http(),
236            credential: None,
237        };
238        request
239            .extensions()
240            .insert(ProxyRoute::Proxy(proxy_address.clone()));
241
242        let proxied_id = BasicHttpConnIdentifier.id(&request).unwrap();
243        assert_eq!(proxied_id.proxy_address, Some(proxy_address));
244    }
245
246    fn proxy_route(name: &str) -> ProxyRoute {
247        ProxyRoute::Proxy(
248            format!("http://{name}.example:8080")
249                .parse::<ProxyAddress>()
250                .unwrap(),
251        )
252    }
253
254    #[tokio::test]
255    async fn route_order_checks_pool_per_selected_route() {
256        let attempts = Arc::new(parking_lot::Mutex::new(Vec::new()));
257        let inner = service_fn({
258            let attempts = attempts.clone();
259            move |input: ConnectRequest| {
260                let attempts = attempts.clone();
261                async move {
262                    let route = input.extensions.get_ref::<ProxyRoute>().unwrap();
263                    let name = route
264                        .proxy_address()
265                        .map(|proxy| proxy.address.host.to_string())
266                        .unwrap_or_else(|| "DIRECT".to_owned());
267                    attempts.lock().push(name.clone());
268
269                    if name == "c.example" {
270                        Ok(EstablishedClientConnection {
271                            input,
272                            conn: ServiceInput::new(()),
273                        })
274                    } else {
275                        Err(ConnectionError::transport(
276                            BoxError::from_static_str("route unavailable"),
277                            ConnectionErrorKind::Unavailable,
278                        ))
279                    }
280                }
281            }
282        });
283        let pool = MultiplexPool::try_new(10, 10).unwrap();
284        let pooled = PooledConnector::new(inner, pool, BasicHttpConnIdentifier);
285        let connector = ProxyRoutesConnector::new(pooled);
286
287        let first = ConnectRequest::new(HostWithPort::example_domain_https())
288            .with_application_protocol(Protocol::HTTPS);
289        first.extensions.insert(ProxyRoutes::new([
290            proxy_route("a"),
291            proxy_route("b"),
292            proxy_route("c"),
293        ]));
294        let established = connector.serve(first).await.unwrap();
295        assert_eq!(
296            established
297                .input
298                .extensions
299                .get_ref::<ProxyRoute>()
300                .unwrap(),
301            &proxy_route("c")
302        );
303        drop(established.conn);
304
305        // Route a is still preferred and attempted first. Route c then reuses
306        // the existing pooled connection instead of calling the inner connector.
307        let second = ConnectRequest::new(HostWithPort::example_domain_https())
308            .with_application_protocol(Protocol::HTTPS);
309        second
310            .extensions
311            .insert(ProxyRoutes::new([proxy_route("a"), proxy_route("c")]));
312        drop(connector.serve(second).await.unwrap().conn);
313
314        // A later request selecting only c shares the exact same pool identity.
315        let third = ConnectRequest::new(HostWithPort::example_domain_https())
316            .with_application_protocol(Protocol::HTTPS);
317        third.extensions.insert(proxy_route("c"));
318        drop(connector.serve(third).await.unwrap().conn);
319
320        assert_eq!(
321            attempts.lock().as_slice(),
322            ["a.example", "b.example", "c.example", "a.example"]
323        );
324    }
325
326    /// A mock connector whose every backend connection runs an `HttpServer` that
327    /// tags each response with `x-conn-id` (which backend connection served it) and
328    /// `x-resp-id` (how many requests that connection has served so far). The
329    /// per-connection id is read from a header, so it can be asserted without
330    /// draining the (possibly still in-flight) response body.
331    fn tagging_mock_connector() -> impl ConnectorService<
332        ConnectRequest,
333        Connection: Service<Request, Output = Response, Error = BoxError> + ExtensionsRef,
334    > {
335        let conns = Arc::new(AtomicUsize::new(0));
336        HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
337            let conn_id = conns.fetch_add(1, Ordering::Relaxed);
338            let resps = Arc::new(AtomicUsize::new(0));
339            HttpServer::auto(Executor::default()).service(service_fn(move |_req: Request| {
340                let resps = resps.clone();
341                async move {
342                    let resp_id = resps.fetch_add(1, Ordering::Relaxed);
343                    let mut resp = Response::new(Body::from("ok"));
344                    let headers = resp.headers_mut();
345                    headers.insert("x-conn-id", HeaderValue::from(conn_id as u64));
346                    headers.insert("x-resp-id", HeaderValue::from(resp_id as u64));
347                    Ok::<_, Infallible>(resp)
348                }
349            }))
350        }))
351    }
352
353    fn conn_id(resp: &Response) -> u64 {
354        resp.headers()
355            .get("x-conn-id")
356            .unwrap()
357            .to_str()
358            .unwrap()
359            .parse()
360            .unwrap()
361    }
362
363    #[tokio::test]
364    async fn pool_keeps_h2_connection_in_use_until_response_body_consumed() {
365        let connector = build_test_connector(
366            HttpPooledConnectorConfig {
367                max_concurrent_streams: 1,
368                max_total: 4,
369                ..Default::default()
370            },
371            tagging_mock_connector(),
372        );
373
374        let req = || create_test_request(Version::HTTP_2);
375
376        // Serve req1 but hold its (still-streaming) response body: the connection is
377        // moved into that body, so it is logically still in use.
378        let est1 = connector.serve(req()).await.unwrap();
379        let resp1 = est1.conn.serve(req()).await.unwrap();
380        drop(est1);
381
382        // req2 must therefore land on a NEW connection (cap = 1, conn 0 busy).
383        let est2 = connector.serve(req()).await.unwrap();
384        let resp2 = est2.conn.serve(req()).await.unwrap();
385
386        assert_eq!(conn_id(&resp1), 0);
387        assert_eq!(
388            conn_id(&resp2),
389            1,
390            "second request must not reuse a connection whose response body is still in flight"
391        );
392    }
393
394    #[tokio::test]
395    async fn pool_keeps_h1_connection_in_use_until_response_body_consumed() {
396        let connector = build_test_connector(
397            HttpPooledConnectorConfig {
398                max_concurrent_streams: 1,
399                max_total: 4,
400                ..Default::default()
401            },
402            tagging_mock_connector(),
403        );
404
405        let req = || create_test_request(Version::HTTP_11);
406
407        let est1 = connector.serve(req()).await.unwrap();
408        let resp1 = est1.conn.serve(req()).await.unwrap();
409        drop(est1);
410
411        let est2 = connector.serve(req()).await.unwrap();
412        let resp2 = est2.conn.serve(req()).await.unwrap();
413
414        assert_eq!(conn_id(&resp1), 0);
415        assert_eq!(
416            conn_id(&resp2),
417            1,
418            "h1: second request must not reuse a connection whose response body is still in flight"
419        );
420    }
421
422    /// An h1 connection the server closed (`Connection: close`) must not be handed
423    /// back out by the pool. Multiplex storage keeps the connection after a handout
424    /// drops, so this relies on `ConnectionHealthWatcher` being marked broken and
425    /// swept, the multiplex equivalent of the exclusive pool's `drop_connection_if_no_response`.
426    #[tokio::test(start_paused = true)]
427    async fn pool_does_not_reuse_h1_connection_after_server_close() {
428        let conns = Arc::new(AtomicUsize::new(0));
429        let inner =
430            HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
431                let conn_id = conns.fetch_add(1, Ordering::Relaxed);
432                HttpServer::auto(Executor::default()).service(service_fn(
433                    move |_req: Request| async move {
434                        let mut resp = Response::new(Body::from("ok"));
435                        let headers = resp.headers_mut();
436                        headers.insert("x-conn-id", HeaderValue::from(conn_id as u64));
437                        headers.insert("connection", HeaderValue::from_static("close"));
438                        Ok::<_, Infallible>(resp)
439                    },
440                ))
441            }));
442        let connector = build_test_connector(
443            HttpPooledConnectorConfig {
444                max_total: 4,
445                ..Default::default()
446            },
447            inner,
448        );
449
450        let req = || create_test_request(Version::HTTP_11);
451
452        // Serve and fully drain req1 so the h1 connection processes the close and
453        // (should) get marked broken before it could be reused.
454        let est1 = connector.serve(req()).await.unwrap();
455        let resp1 = est1.conn.serve(req()).await.unwrap();
456        drop(est1);
457        let id1 = conn_id(&resp1);
458        resp1.into_body().collect().await.unwrap();
459
460        // Let the connection task observe the close and update health.
461        sleep(Duration::from_millis(50)).await;
462
463        let est2 = connector.serve(req()).await.unwrap();
464        let resp2 = est2.conn.serve(req()).await.unwrap();
465
466        assert_eq!(id1, 0);
467        assert_ne!(
468            conn_id(&resp2),
469            id1,
470            "must not reuse an h1 connection the server closed"
471        );
472    }
473
474    #[tokio::test]
475    async fn pool_does_not_reuse_h1_connection_after_upgrade() {
476        let conns = Arc::new(AtomicUsize::new(0));
477        let inner =
478            HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
479                let conn_id = conns.fetch_add(1, Ordering::Relaxed);
480                HttpServer::auto(Executor::default()).service(service_fn(
481                    move |req: Request| async move {
482                        let is_upgrade = req.uri().path().is_some_and(|path| path == "/upgrade");
483                        if is_upgrade {
484                            let on_upgrade = rama_http::io::upgrade::handle_upgrade(&req);
485                            _ = tokio::spawn(async move {
486                                _ = on_upgrade.await;
487                            });
488                        }
489
490                        let mut resp = if is_upgrade {
491                            Response::builder()
492                                .status(StatusCode::SWITCHING_PROTOCOLS)
493                                .header("connection", "upgrade")
494                                .header("upgrade", "test")
495                                .body(Body::empty())
496                                .unwrap()
497                        } else {
498                            Response::new(Body::from("ok"))
499                        };
500                        resp.headers_mut()
501                            .insert("x-conn-id", HeaderValue::from(conn_id as u64));
502                        Ok::<_, Infallible>(resp)
503                    },
504                ))
505            }));
506        let connector = build_test_connector(HttpPooledConnectorConfig::default(), inner);
507
508        let upgrade_request = Request::builder()
509            .uri("https://www.example.com/upgrade")
510            .version(Version::HTTP_11)
511            .header("connection", "upgrade")
512            .header("upgrade", "test")
513            .body(Body::empty())
514            .unwrap();
515        let established = connector.serve(upgrade_request).await.unwrap();
516        let response = established.conn.serve(established.input).await.unwrap();
517        let first_conn_id = conn_id(&response);
518        assert_eq!(
519            established
520                .conn
521                .extensions()
522                .get_ref::<ConnectionHealthWatcher>()
523                .expect("pooled h1 connection health watcher")
524                .health(),
525            ConnectionHealth::Broken,
526            "an h1 upgrade must be marked broken before returning its response"
527        );
528        let on_upgrade = rama_http::io::upgrade::handle_upgrade(&response);
529        let body = response.into_body();
530        drop(body);
531
532        let request = create_test_request(Version::HTTP_11);
533        let established = connector.serve(request).await.unwrap();
534        let response = established.conn.serve(established.input).await.unwrap();
535        assert_ne!(
536            conn_id(&response),
537            first_conn_id,
538            "an upgraded h1 connection must be evicted before the next request"
539        );
540        drop(on_upgrade.await.unwrap());
541    }
542
543    /// An h1 response body abandoned before end-of-stream leaves the connection
544    /// mid-message, the pool must not reuse it. Uses a large body so the response is
545    /// genuinely still on the wire when dropped (a tiny fully-buffered body could be
546    /// drained and legitimately reused).
547    #[tokio::test(start_paused = true)]
548    async fn pool_does_not_reuse_h1_connection_after_body_dropped_early() {
549        let conns = Arc::new(AtomicUsize::new(0));
550        let inner =
551            HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
552                let conn_id = conns.fetch_add(1, Ordering::Relaxed);
553                HttpServer::auto(Executor::default()).service(service_fn(
554                    move |_req: Request| async move {
555                        let mut resp = Response::new(Body::from(vec![0u8; kib(1024)]));
556                        resp.headers_mut()
557                            .insert("x-conn-id", HeaderValue::from(conn_id as u64));
558                        Ok::<_, Infallible>(resp)
559                    },
560                ))
561            }));
562        let connector = build_test_connector(
563            HttpPooledConnectorConfig {
564                max_total: 4,
565                ..Default::default()
566            },
567            inner,
568        );
569
570        let req = || create_test_request(Version::HTTP_11);
571
572        // Take the response (headers) but drop it without reading the body.
573        let est1 = connector.serve(req()).await.unwrap();
574        let resp1 = est1.conn.serve(req()).await.unwrap();
575        drop(est1);
576        let id1 = conn_id(&resp1);
577        drop(resp1);
578
579        // Let the connection task observe the abandoned read and update health.
580        sleep(Duration::from_millis(50)).await;
581
582        let est2 = connector.serve(req()).await.unwrap();
583        let resp2 = est2.conn.serve(req()).await.unwrap();
584
585        assert_eq!(id1, 0);
586        assert_ne!(
587            conn_id(&resp2),
588            id1,
589            "must not reuse an h1 connection whose response body was abandoned mid-stream"
590        );
591    }
592
593    #[tokio::test]
594    async fn pool_reuses_connection_after_body_consumed() {
595        let connector = build_test_connector(
596            HttpPooledConnectorConfig {
597                max_concurrent_streams: 1,
598                max_total: 4,
599                ..Default::default()
600            },
601            tagging_mock_connector(),
602        );
603
604        let req = || create_test_request(Version::HTTP_2);
605
606        let resp1 = connector
607            .serve(req())
608            .await
609            .unwrap()
610            .conn
611            .serve(req())
612            .await
613            .unwrap();
614        assert_eq!(conn_id(&resp1), 0);
615        // Drain to end-of-stream: releases connection 0 back to the pool.
616        resp1.into_body().collect().await.unwrap();
617
618        let resp2 = connector
619            .serve(req())
620            .await
621            .unwrap()
622            .conn
623            .serve(req())
624            .await
625            .unwrap();
626        assert_eq!(
627            conn_id(&resp2),
628            0,
629            "connection must be reused once its response body is consumed"
630        );
631    }
632
633    #[tokio::test]
634    async fn pool_multiplexes_on_h2() {
635        let connector = build_test_connector(
636            HttpPooledConnectorConfig::default(),
637            tagging_mock_connector(),
638        );
639
640        let req = || create_test_request(Version::HTTP_2);
641
642        // Hold resp1 (body unconsumed) so connection 0 stays in use.
643        let resp1 = connector
644            .serve(req())
645            .await
646            .unwrap()
647            .conn
648            .serve(req())
649            .await
650            .unwrap();
651        let resp2 = connector
652            .serve(req())
653            .await
654            .unwrap()
655            .conn
656            .serve(req())
657            .await
658            .unwrap();
659
660        assert_eq!(conn_id(&resp1), 0);
661        assert_eq!(
662            conn_id(&resp2),
663            0,
664            "h2: a second in-flight request multiplexes onto the same connection"
665        );
666    }
667
668    #[tokio::test]
669    async fn pool_does_not_multiplex_on_h1() {
670        let connector = build_test_connector(
671            HttpPooledConnectorConfig::default(),
672            tagging_mock_connector(),
673        );
674
675        let req = || create_test_request(Version::HTTP_11);
676
677        let resp1 = connector
678            .serve(req())
679            .await
680            .unwrap()
681            .conn
682            .serve(req())
683            .await
684            .unwrap();
685        let resp2 = connector
686            .serve(req())
687            .await
688            .unwrap()
689            .conn
690            .serve(req())
691            .await
692            .unwrap();
693
694        assert_eq!(conn_id(&resp1), 0);
695        assert_eq!(
696            conn_id(&resp2),
697            1,
698            "h1 does not multiplex: a second in-flight request needs a new connection"
699        );
700    }
701
702    #[tokio::test]
703    async fn pool_respects_max_concurrent_streams() {
704        let connector = build_test_connector(
705            HttpPooledConnectorConfig {
706                max_concurrent_streams: 2,
707                max_total: 4,
708                ..Default::default()
709            },
710            tagging_mock_connector(),
711        );
712
713        let req = || create_test_request(Version::HTTP_2);
714
715        // Three in-flight (bodies unconsumed) requests: two fit on connection 0.
716        let resp1 = connector
717            .serve(req())
718            .await
719            .unwrap()
720            .conn
721            .serve(req())
722            .await
723            .unwrap();
724        let resp2 = connector
725            .serve(req())
726            .await
727            .unwrap()
728            .conn
729            .serve(req())
730            .await
731            .unwrap();
732        let resp3 = connector
733            .serve(req())
734            .await
735            .unwrap()
736            .conn
737            .serve(req())
738            .await
739            .unwrap();
740
741        assert_eq!(conn_id(&resp1), 0);
742        assert_eq!(conn_id(&resp2), 0, "second request fits on connection 0");
743        assert_eq!(
744            conn_id(&resp3),
745            1,
746            "third request exceeds the per-connection limit, needs a new connection"
747        );
748    }
749
750    /// Like [`tagging_mock_connector`] but each response carries a large body, so
751    /// it genuinely streams over multiple frames rather than a single buffered one.
752    fn large_body_mock_connector() -> impl ConnectorService<
753        ConnectRequest,
754        Connection: Service<Request, Output = Response, Error = BoxError> + ExtensionsRef,
755    > {
756        let conns = Arc::new(AtomicUsize::new(0));
757        HttpConnectorLayer::default().into_layer(MockConnectorService::new(move || {
758            let conn_id = conns.fetch_add(1, Ordering::Relaxed);
759            HttpServer::auto(Executor::default()).service(service_fn(
760                move |_req: Request| async move {
761                    let mut resp = Response::new(Body::from(vec![0u8; kib(1024)]));
762                    resp.headers_mut()
763                        .insert("x-conn-id", HeaderValue::from(conn_id as u64));
764                    Ok::<_, Infallible>(resp)
765                },
766            ))
767        }))
768    }
769
770    /// A connection stays bound for the whole of a *streaming* response body: it
771    /// is not reusable while frames are still arriving, and is released precisely
772    /// at end-of-stream (exercising `GuardedBody`'s `poll_frame -> Ready(None)`
773    /// over real multi-frame h2 streaming).
774    #[tokio::test]
775    async fn pool_binds_connection_across_streaming_body() {
776        let connector = build_test_connector(
777            HttpPooledConnectorConfig {
778                max_concurrent_streams: 1,
779                max_total: 4,
780                ..Default::default()
781            },
782            large_body_mock_connector(),
783        );
784
785        let req = || create_test_request(Version::HTTP_2);
786
787        // Read the headers and a single body frame: the stream is not yet at
788        // end-of-stream, so connection 0 is still in use.
789        let resp1 = connector
790            .serve(req())
791            .await
792            .unwrap()
793            .conn
794            .serve(req())
795            .await
796            .unwrap();
797        let id1 = conn_id(&resp1);
798        let mut body1 = resp1.into_body();
799        assert!(
800            body1.frame().await.is_some(),
801            "streaming body should yield at least one frame"
802        );
803
804        // Mid-stream: connection 0 must not be reused.
805        let resp2 = connector
806            .serve(req())
807            .await
808            .unwrap()
809            .conn
810            .serve(req())
811            .await
812            .unwrap();
813        assert_eq!(id1, 0);
814        assert_ne!(
815            conn_id(&resp2),
816            id1,
817            "a connection still streaming its response body must not be reused"
818        );
819
820        // Drain body 1 to end-of-stream. `GuardedBody` releases the connection
821        // right here (at `poll_frame -> Ready(None)`), not on drop.
822        while let Some(frame) = body1.frame().await {
823            frame.unwrap();
824        }
825
826        // `body1` is still in scope (not dropped), yet connection 0 is reused.
827        // Proving the release happens at end-of-stream, not when the body drops.
828        let resp3 = connector
829            .serve(req())
830            .await
831            .unwrap()
832            .conn
833            .serve(req())
834            .await
835            .unwrap();
836        assert_eq!(
837            conn_id(&resp3),
838            id1,
839            "a connection is reused once its streaming body reaches end-of-stream"
840        );
841    }
842}