Skip to main content

ringline_http/
pool.rs

1//! Connection pool for HTTP clients.
2//!
3//! Fixed-size pool with round-robin dispatch and lazy reconnection.
4//! Follows the `ringline-momento/src/pool.rs` pattern.
5
6use std::net::SocketAddr;
7use std::ops::{Deref, DerefMut};
8
9use crate::client::HttpClient;
10use crate::error::HttpError;
11
12/// Configuration for an HTTP connection pool.
13pub struct PoolConfig {
14    /// Server address to connect to.
15    pub addr: SocketAddr,
16    /// Host name (for TLS SNI and Host header).
17    pub host: String,
18    /// Number of connections in the pool.
19    pub pool_size: usize,
20    /// Protocol to use.
21    pub protocol: Protocol,
22    /// Connect timeout in milliseconds. 0 means no timeout.
23    pub connect_timeout_ms: u64,
24}
25
26/// Which HTTP protocol to use.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Protocol {
29    /// HTTP/2 over TLS.
30    H2,
31    /// HTTP/1.1 over TLS.
32    H1,
33    /// HTTP/1.1 over plaintext TCP.
34    H1Plain,
35}
36
37enum Slot {
38    Connected(Box<HttpClient>),
39    Disconnected,
40}
41
42/// A fixed-size HTTP connection pool with round-robin dispatch.
43pub struct Pool {
44    addr: SocketAddr,
45    host: String,
46    protocol: Protocol,
47    slots: Vec<Slot>,
48    next: usize,
49    connect_timeout_ms: u64,
50}
51
52impl Pool {
53    /// Create a new pool. All slots start disconnected.
54    pub fn new(config: PoolConfig) -> Self {
55        let mut slots = Vec::with_capacity(config.pool_size);
56        for _ in 0..config.pool_size {
57            slots.push(Slot::Disconnected);
58        }
59        Pool {
60            addr: config.addr,
61            host: config.host,
62            protocol: config.protocol,
63            slots,
64            next: 0,
65            connect_timeout_ms: config.connect_timeout_ms,
66        }
67    }
68
69    /// Eagerly connect all slots.
70    pub async fn connect_all(&mut self) -> Result<(), HttpError> {
71        for i in 0..self.slots.len() {
72            let client = self.do_connect().await?;
73            self.slots[i] = Slot::Connected(Box::new(client));
74        }
75        Ok(())
76    }
77
78    /// Get a client bound to the next healthy connection.
79    ///
80    /// Advances the round-robin cursor. Disconnected slots are lazily
81    /// reconnected. Returns [`HttpError::AllConnectionsFailed`] if all
82    /// slots fail.
83    ///
84    /// The returned [`PooledClient`] derefs to `&mut HttpClient` and, on
85    /// drop, recycles its slot if the peer signalled close
86    /// ([`HttpClient::peer_will_close`]). The next call to `client()`
87    /// will lazily reconnect that slot.
88    pub async fn client(&mut self) -> Result<PooledClient<'_>, HttpError> {
89        let size = self.slots.len();
90        for _ in 0..size {
91            let idx = self.next;
92            self.next = (self.next + 1) % size;
93
94            match &self.slots[idx] {
95                Slot::Connected(_) => {
96                    return Ok(PooledClient { pool: self, idx });
97                }
98                Slot::Disconnected => {
99                    if let Ok(client) = self.do_connect().await {
100                        self.slots[idx] = Slot::Connected(Box::new(client));
101                        return Ok(PooledClient { pool: self, idx });
102                    }
103                }
104            }
105        }
106        Err(HttpError::AllConnectionsFailed)
107    }
108
109    /// Mark a slot as disconnected by index, closing the underlying connection.
110    pub fn mark_disconnected(&mut self, idx: usize) {
111        if idx < self.slots.len() {
112            if let Slot::Connected(client) = &self.slots[idx] {
113                client.close();
114            }
115            self.slots[idx] = Slot::Disconnected;
116        }
117    }
118
119    /// Close all connections.
120    pub fn close_all(&mut self) {
121        for slot in &mut self.slots {
122            if let Slot::Connected(client) = slot {
123                client.close();
124            }
125            *slot = Slot::Disconnected;
126        }
127    }
128
129    /// Number of currently connected slots.
130    pub fn connected_count(&self) -> usize {
131        self.slots
132            .iter()
133            .filter(|s| matches!(s, Slot::Connected(_)))
134            .count()
135    }
136
137    /// Total number of slots in the pool.
138    pub fn pool_size(&self) -> usize {
139        self.slots.len()
140    }
141
142    async fn do_connect(&self) -> Result<HttpClient, HttpError> {
143        match self.protocol {
144            Protocol::H2 => {
145                if self.connect_timeout_ms > 0 {
146                    HttpClient::connect_h2_with_timeout(
147                        self.addr,
148                        &self.host,
149                        self.connect_timeout_ms,
150                    )
151                    .await
152                } else {
153                    HttpClient::connect_h2(self.addr, &self.host).await
154                }
155            }
156            Protocol::H1 => HttpClient::connect_h1(self.addr, &self.host).await,
157            Protocol::H1Plain => HttpClient::connect_h1_plain(self.addr, &self.host).await,
158        }
159    }
160}
161
162/// A pool-owned handle to an [`HttpClient`].
163///
164/// Borrows the pool exclusively while alive; derefs to `&mut HttpClient`.
165/// On drop, the guard inspects [`HttpClient::peer_will_close`] and
166/// recycles its slot (closes the underlying connection and marks the
167/// slot for lazy reconnect) when the peer has signalled close — avoiding
168/// the request-smuggling-class hazard of sending a fresh request on a
169/// connection the server is about to close.
170pub struct PooledClient<'a> {
171    pool: &'a mut Pool,
172    idx: usize,
173}
174
175impl PooledClient<'_> {
176    /// Index of the underlying slot in the pool. Useful for callers that
177    /// want to explicitly recycle (e.g., on an application-level error
178    /// the guard's `peer_will_close` check would not catch).
179    pub fn slot(&self) -> usize {
180        self.idx
181    }
182}
183
184impl Deref for PooledClient<'_> {
185    type Target = HttpClient;
186
187    fn deref(&self) -> &HttpClient {
188        match &self.pool.slots[self.idx] {
189            Slot::Connected(client) => client,
190            // `client()` only constructs `PooledClient` for a Connected
191            // slot, and we hold &mut Pool exclusively for the guard's
192            // lifetime, so the slot cannot transition under us.
193            Slot::Disconnected => unreachable!("PooledClient over Disconnected slot"),
194        }
195    }
196}
197
198impl DerefMut for PooledClient<'_> {
199    fn deref_mut(&mut self) -> &mut HttpClient {
200        match &mut self.pool.slots[self.idx] {
201            Slot::Connected(client) => client,
202            Slot::Disconnected => unreachable!("PooledClient over Disconnected slot"),
203        }
204    }
205}
206
207impl Drop for PooledClient<'_> {
208    fn drop(&mut self) {
209        let should_recycle = match &self.pool.slots[self.idx] {
210            Slot::Connected(client) => client.peer_will_close(),
211            Slot::Disconnected => false,
212        };
213        if should_recycle {
214            self.pool.mark_disconnected(self.idx);
215        }
216    }
217}